Compare commits

...

303 Commits

Author SHA1 Message Date
kyori19 7a140a9deb
Bump version to v4.7.0 (57) 2023-12-08 02:29:18 +09:00
kyori19 9b5f176008
Merge remote-tracking branch 'tuskyapp/develop' 2023-12-08 02:14:28 +09:00
Goooler 1313371051
Optimise repositories declarations (#4113) 2023-11-23 09:41:46 +01:00
Levi Bard 2d663d8966
Translations update from Weblate (#4119)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widget/tusky/tusky/horizontal-auto.svg)
2023-11-23 09:41:08 +01:00
mcclure 70725fd75b
Regularize show/hide logic for video player scrub/play controls (fixes #4073) (#4117)
When viewing a video in Tusky, there is a top toolbar where the
description is shown and the bottom toolbar where play, forward,
backward, and scrub controls are found. In both Tusky 23 and the new
media3 video player code, the logic for showing these toolbars is
*unrelated*; Tusky catches tap events and shows and hides the
description, and the Android media library separately catches tap events
and shows and hides the bottom toolbar. Meanwhile, Tusky and the Android
media library each separately manage a set of logic for auto-hiding
their respective toolbars after a certain number of seconds has passed.
This all results in several problems:

- The top and bottom toolbars can desync, so that one is visible and the
other is not, and tapping to show/hide after this will only swap which
one is visible. This happens *every* time you switch to another
application then back to Tusky while the video player is up.
- You can also desync the top and bottom toolbars in this way by simply
tapping very rapidly.
- The autohide logic was difficult for us to control or customize,
because it was partially hidden inside the Android libraries (relevant
because under media3, the autohide delay increased from 3 to something
like 5 or 6 seconds).

In this patch, I disabled all auto- and tap-based show/hide logic in
media3 and set the Tusky-side show/hide to directly control the media3
toolbar. I then audited the code with printfs until I understood the
state machine of show/hide, and removed anything irrational (some code
was either unreachable, or redundant; either these lines were broken in
the media3 transition, or they never worked).¹

While doing this, I made two policy changes:

- As discussed on Matrix, the autohide delay is now 4 seconds. (In
discussions with users on Mastodon, some complained the previous 3
seconds was too short; but in my opinion and [I think?] charlag's, the
new 5 seconds is too long).
- In the pre-existing code, if the user has hidden the controls, and
they switch to another app and back, the controls display for 4 seconds
then re-hide themselves, just like if the video had been presented for
the first time. I think this is good and kept it— *however* I made a
decision if the user intentionally taps to display the controls, *then*
switches to another app and back, the controls should *not* auto-hide,
because the user most recently requested those controls be shown.

Tests I performed on the final PR (successfully):

- Start video. Expect: toolbar+description hides after 4 seconds.
- Start video. Pause. Resume. Expect: t+d hides after 4 seconds.
- Start video. Wait 4 seconds until t+d hide. Switch to other app.
Switch back. Expect: t+d reappears, then hides after 4 seconds.
- Start video. Wait 4 seconds until t+d hide. Tap to show t+d. Switch to
other app. Switch back. Expect: t+d appear, do NOT autohide.
- Start video. Before 4 seconds up, switch to other app. Switch back.
Expect: t+d reappears, then hides after 4 seconds.
- Start video. Pause. Resume. Before 4 seconds up, switch to other app.
Switch back. Expect: t+d reappears, then hides after 4 seconds.
- Start video. Wait 4 seconds until t+d hide. Tap rapidly over and over
for many seconds. Expect: Nothing weird
- Start *audio*. Expect: At no point does controller autohide, not even
if I switch to another app and back, but I can hide it by manually
tapping

These tests were performed on Android 13. There is an entirely separate
`Build.VERSION.SDK_INT <= 23` path I did not test, but Android Studio
says this is dead code (I think it thinks our minimum SDK is higher than
that?)

---

<small>¹ Incidentally, the underlying cause of #4073 (the show/resume
part of it anyway) turned out to be that the STATE_READY event was being
received not just on video load but also a second time on app resume,
causing certain parts of the initialization code to run a second time
although the fragment had already been fully initialized.</small>
2023-11-23 08:32:01 +01:00
UlrichKu 8efe3a96b7
3820: Group android notifications and properly use the "group summary alert" (#3821)
Fixes #3820 

This mainly corrects the "first of batch" logic.
(Each group/batch has a list so the question "only one per this group?"
can be answered.)
2023-11-23 08:25:00 +01:00
Levi Bard f9ef0d36c2
Translations update from Weblate (#4111)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widget/tusky/tusky/horizontal-auto.svg)
2023-11-23 08:24:26 +01:00
Ümit Solmaz 4e37d4f10f Translated using Weblate (Turkish)
Currently translated at 100.0% (634 of 634 strings)

Co-authored-by: Ümit Solmaz <usnotv@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-11-23 07:04:18 +00:00
Conny Duck 11e7845ee3 Release 114 (24.0 beta 1) 2023-11-22 20:15:11 +01:00
Quentí ca1b04db60 Translated using Weblate (Occitan)
Currently translated at 97.9% (621 of 634 strings)

Co-authored-by: Quentí <quentinantonin@free.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/
Translation: Tusky/Tusky
2023-11-20 04:45:01 +00:00
Manuel 63c708e594 Translated using Weblate (Italian)
Currently translated at 100.0% (634 of 634 strings)

Co-authored-by: Manuel <mannivuwiki@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/
Translation: Tusky/Tusky
2023-11-20 04:45:00 +00:00
Danial Behzadi 47cf36c9fe Translated using Weblate (Persian)
Currently translated at 100.0% (634 of 634 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-11-20 04:45:00 +00:00
puf e26a383495 Translated using Weblate (Welsh)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Welsh)

Currently translated at 100.0% (634 of 634 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-11-20 04:45:00 +00:00
Hồ Nhất Duy cb9bf588ab Translated using Weblate (Vietnamese)
Currently translated at 100.0% (634 of 634 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-11-20 04:45:00 +00:00
Luna Jernberg 6722a32504 Translated using Weblate (Swedish)
Currently translated at 100.0% (634 of 634 strings)

Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sv/
Translation: Tusky/Tusky
2023-11-20 04:45:00 +00:00
Levi Bard 21a4308fef
Fix deserialization of the response from friendica on api/v2/instance (#4103)
Fixes #4100

---------

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
2023-11-13 10:05:28 +01:00
sanao ff39f9b3c2
feat: Hide self-boosts (#3534) (#4101)
# Overview
Some Mastodon posters have a annoying habit of boosting their own posts
some time after they've posted them.
 No need to see the same toot over and over again.

# Changes made
- Add an additional option to the "Filters > Tabs" preference to show
these self-boosts (default: on)
- If "Show boosts" is turned off, self-boosts are automatically hidden.
    
# Screenshot
***screen of "Filters > Tabs" preference***
before | after
:--: | :--:
| <image
src="https://github.com/tuskyapp/Tusky/assets/62137820/16ed2f4a-0776-4f60-afe6-29827acf5bbd"
width="300"> |<image
src="https://github.com/tuskyapp/Tusky/assets/62137820/9d4e1457-f71d-440c-959f-b91f7433b29a"
width="300" />

***screen of Home Timeline***
***switch-on(self-boosts are displayed)*** | ***swith-off(self-boosts
are not displayed)***
:--: | :--:
| <image
src="https://github.com/tuskyapp/Tusky/assets/62137820/3bb80791-a81f-4cbc-98ad-8a14602e53a4"
width="300" />|<image
src="https://github.com/tuskyapp/Tusky/assets/62137820/a7964da8-d106-4209-b911-460ef8988831"
width="300" />

# Related issue
 Fixes #3534
2023-11-13 10:04:39 +01:00
Levi Bard c6948878a8
Translations update from Weblate (#4105)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky
description](https://weblate.tusky.app/projects/tusky/tusky-app/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widget/tusky/tusky-app/horizontal-auto.svg)
2023-11-13 10:02:56 +01:00
Levi Bard a326134ed9
Translations update from Weblate (#4099)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widget/tusky/tusky/horizontal-auto.svg)
2023-11-13 09:30:45 +01:00
Luna Jernberg 03be46014a Translated using Weblate (Swedish)
Currently translated at 100.0% (31 of 31 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/sv/
2023-11-13 08:03:46 +00:00
Levi Bard dc26e6bb7b
Update ksp to track kotlin version update (#4104) 2023-11-09 18:54:09 +01:00
Luna Jernberg 9ebd484e24 Translated using Weblate (Swedish)
Currently translated at 100.0% (632 of 632 strings)

Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sv/
Translation: Tusky/Tusky
2023-11-09 17:27:27 +00:00
Rhoslyn Prys 71e8f1e213 Translated using Weblate (Welsh)
Currently translated at 100.0% (632 of 632 strings)

Co-authored-by: Rhoslyn Prys <post@meddal.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-11-09 17:27:27 +00:00
XoseM 6942c7b9b6 Translated using Weblate (Galician)
Currently translated at 100.0% (632 of 632 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-11-09 17:27:27 +00:00
Hồ Nhất Duy 3952b99a88 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (632 of 632 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-11-09 17:27:27 +00:00
renovate[bot] e919584682
Update dagger to v2.48.1 (#4094)
[![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.47` -> `2.48.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.google.dagger:dagger/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.google.dagger:dagger/2.47/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:dagger/2.47/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[com.google.dagger:dagger-compiler](https://togithub.com/google/dagger)
| `2.47` -> `2.48.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger-compiler/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.google.dagger:dagger-compiler/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.google.dagger:dagger-compiler/2.47/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:dagger-compiler/2.47/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[com.google.dagger:dagger-android-support](https://togithub.com/google/dagger)
| `2.47` -> `2.48.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger-android-support/2.48.1?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.48.1?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.47/2.48.1?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.47/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[com.google.dagger:dagger-android-processor](https://togithub.com/google/dagger)
| `2.47` -> `2.48.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger-android-processor/2.48.1?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.48.1?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.47/2.48.1?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.47/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [com.google.dagger:dagger-android](https://togithub.com/google/dagger)
| `2.47` -> `2.48.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger-android/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.google.dagger:dagger-android/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.google.dagger:dagger-android/2.47/2.48.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:dagger-android/2.47/2.48.1?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:eyJjcmVhdGVkSW5WZXIiOiIzNy4zMS41IiwidXBkYXRlZEluVmVyIjoiMzcuMzEuNSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-01 12:37:02 +01:00
Nick Schonning bf50d9a712
Use Renovate recommended baseline (#3977)
Notice this was done upstream in
https://github.com/mastodon/mastodon/pull/26306 by one of the Renovate
people, so thought it would make sense here too
2023-11-01 11:39:20 +01:00
renovate[bot] 955ad2df37
Update dependency androidx.preference:preference-ktx to v1.2.1 (#3944)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
|
[androidx.preference:preference-ktx](https://developer.android.com/jetpack/androidx/releases/preference#1.2.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.2.0` -> `1.2.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.preference:preference-ktx/1.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.preference:preference-ktx/1.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.preference:preference-ktx/1.2.0/1.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.preference:preference-ktx/1.2.0/1.2.1?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 this update
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:eyJjcmVhdGVkSW5WZXIiOiIzNi40MC4zIiwidXBkYXRlZEluVmVyIjoiMzcuMzEuNSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-01 11:39:08 +01:00
renovate[bot] b7841f2ff2
Update autodispose to v2.2.1 (#3873)
[![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.uber.autodispose2:autodispose](https://togithub.com/uber/AutoDispose)
| `2.1.1` -> `2.2.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.uber.autodispose2:autodispose/2.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.uber.autodispose2:autodispose/2.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.uber.autodispose2:autodispose/2.1.1/2.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.uber.autodispose2:autodispose/2.1.1/2.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[com.uber.autodispose2:autodispose-androidx-lifecycle](https://togithub.com/uber/AutoDispose)
| `2.1.1` -> `2.2.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.uber.autodispose2:autodispose-androidx-lifecycle/2.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.uber.autodispose2:autodispose-androidx-lifecycle/2.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.uber.autodispose2:autodispose-androidx-lifecycle/2.1.1/2.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.uber.autodispose2:autodispose-androidx-lifecycle/2.1.1/2.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>uber/AutoDispose (com.uber.autodispose2:autodispose)</summary>

###
[`v2.2.1`](https://togithub.com/uber/AutoDispose/blob/HEAD/CHANGELOG.md#Version-221)

[Compare
Source](https://togithub.com/uber/AutoDispose/compare/2.2.0...2.2.1)

*2023-07-25*

- **Fix**: Don't hold a reference to the view if detach happens before
`DetachEventCompletable` is disposed.

###
[`v2.2.0`](https://togithub.com/uber/AutoDispose/blob/HEAD/CHANGELOG.md#Version-220)

[Compare
Source](https://togithub.com/uber/AutoDispose/compare/2.1.1...2.2.0)

*2023-07-23*

- **Fix**: Dispose the handle returned by `Job.invokeOnCompletion` when
Rx subscription is disposed.
- **Fix**: The `withScope()` lint now correctly handles different named
arguments order.
- **Fix**: Support kotlin if/when/lambda expressions in lint lenient
mode.
-   Update AndroidX Lifecycle to `2.6.1`.
-   Update Kotlin to `1.9.0`.
-   Update RxJava to `3.1.6`.
-   Update RxAndroid to `3.0.2`.
-   Update lint to `8.0.2`/`31.0.2`.
-   Update error-prone to `2.20.0`.
- Update dokka + fresh coat of paint on API docs on the project site:
https://uber.github.io/AutoDispose/api/2.x/

Special thanks to [@&#8203;alexfu](https://togithub.com/alexfu),
[@&#8203;sanggggg](https://togithub.com/sanggggg), and
[@&#8203;psteiger](https://togithub.com/@&#8203;psteiger) for
contributing to this release!

</details>

---

### 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:eyJjcmVhdGVkSW5WZXIiOiIzNi4xMS4wIiwidXBkYXRlZEluVmVyIjoiMzcuMzEuNSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-01 09:26:49 +01:00
renovate[bot] 00e5795c86
Update Kotlin to v1.9.20 (#4018)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| org.jetbrains.kotlin.plugin.parcelize | `1.9.0` -> `1.9.20` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin.plugin.parcelize/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/org.jetbrains.kotlin.plugin.parcelize/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/org.jetbrains.kotlin.plugin.parcelize/1.9.0/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin.plugin.parcelize/1.9.0/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| org.jetbrains.kotlin.kapt | `1.9.0` -> `1.9.20` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin.kapt/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/org.jetbrains.kotlin.kapt/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/org.jetbrains.kotlin.kapt/1.9.0/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin.kapt/1.9.0/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| org.jetbrains.kotlin.android | `1.9.0` -> `1.9.20` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin.android/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/org.jetbrains.kotlin.android/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/org.jetbrains.kotlin.android/1.9.0/1.9.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin.android/1.9.0/1.9.20?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:eyJjcmVhdGVkSW5WZXIiOiIzNi44My4wIiwidXBkYXRlZEluVmVyIjoiMzcuMzEuNSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-01 09:24:28 +01:00
renovate[bot] 227ddf6a71
Update androidx.lifecycle to v2.6.2 (#4020)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
|
[androidx.lifecycle:lifecycle-viewmodel-ktx](https://developer.android.com/jetpack/androidx/releases/lifecycle#2.6.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.6.1` -> `2.6.2` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.lifecycle:lifecycle-viewmodel-ktx/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.lifecycle:lifecycle-viewmodel-ktx/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.lifecycle:lifecycle-viewmodel-ktx/2.6.1/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.lifecycle:lifecycle-viewmodel-ktx/2.6.1/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[androidx.lifecycle:lifecycle-reactivestreams-ktx](https://developer.android.com/jetpack/androidx/releases/lifecycle#2.6.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.6.1` -> `2.6.2` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.lifecycle:lifecycle-reactivestreams-ktx/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.lifecycle:lifecycle-reactivestreams-ktx/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.lifecycle:lifecycle-reactivestreams-ktx/2.6.1/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.lifecycle:lifecycle-reactivestreams-ktx/2.6.1/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[androidx.lifecycle:lifecycle-livedata-ktx](https://developer.android.com/jetpack/androidx/releases/lifecycle#2.6.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.6.1` -> `2.6.2` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.lifecycle:lifecycle-livedata-ktx/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.lifecycle:lifecycle-livedata-ktx/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.lifecycle:lifecycle-livedata-ktx/2.6.1/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.lifecycle:lifecycle-livedata-ktx/2.6.1/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[androidx.lifecycle:lifecycle-common-java8](https://developer.android.com/jetpack/androidx/releases/lifecycle#2.6.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.6.1` -> `2.6.2` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.lifecycle:lifecycle-common-java8/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.lifecycle:lifecycle-common-java8/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.lifecycle:lifecycle-common-java8/2.6.1/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.lifecycle:lifecycle-common-java8/2.6.1/2.6.2?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:eyJjcmVhdGVkSW5WZXIiOiIzNi44My4wIiwidXBkYXRlZEluVmVyIjoiMzcuMzEuNSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-01 09:24:01 +01:00
renovate[bot] 97cf9a0ea4
Update dependency androidx.paging:paging-runtime-ktx to v3.2.1 (#4021)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
|
[androidx.paging:paging-runtime-ktx](https://developer.android.com/jetpack/androidx/releases/paging#3.2.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.2.0` -> `3.2.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-runtime-ktx/3.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.paging:paging-runtime-ktx/3.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.paging:paging-runtime-ktx/3.2.0/3.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-runtime-ktx/3.2.0/3.2.1?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 this update
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:eyJjcmVhdGVkSW5WZXIiOiIzNi44My4wIiwidXBkYXRlZEluVmVyIjoiMzcuMzEuNSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-01 09:23:43 +01:00
renovate[bot] 2596aa1e0e
Update dependency com.android.application to v8.1.2 (#4078)
[![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.android.application](https://developer.android.com/studio/build)
([source](https://android.googlesource.com/platform/tools/base)) |
`8.1.1` -> `8.1.2` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.android.application/8.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.android.application/8.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.android.application/8.1.1/8.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.android.application/8.1.1/8.1.2?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 this update
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:eyJjcmVhdGVkSW5WZXIiOiIzNy4zMS41IiwidXBkYXRlZEluVmVyIjoiMzcuMzEuNSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-01 09:23:27 +01:00
Konrad Pozniak de70f08066
make blockquotes pretty (#4091)
closes https://github.com/tuskyapp/Tusky/issues/1271

before:
<img
src="https://github.com/tuskyapp/Tusky/assets/10157047/bfb4dcab-a6a7-4373-acf2-337363d63908"
width="380"/>

after:
<img
src="https://github.com/tuskyapp/Tusky/assets/10157047/79d90c22-dfd6-44fa-a600-a0e13d992bfc"
width="380"/>
2023-11-01 09:22:48 +01:00
Konrad Pozniak 6773342b60
Support code blocks (#4090)
before
<img
src="https://github.com/tuskyapp/Tusky/assets/10157047/452b959f-7f97-4d04-a464-0dcf0bf56f79"
width="380"/>

after
<img
src="https://github.com/tuskyapp/Tusky/assets/10157047/0fb5b41c-dda3-4d46-878e-689d6ae51b0a"
width="380"/>
2023-11-01 09:22:23 +01:00
mcclure ede66c4eb8
Exoplayer: Increase space between rewind, pause, ffwd buttons (#4077)
I [posted our new video player layout] on Mastodon for comments and
multiple people said the buttons were too close together. I agree. I
added some space (I eyeballed it, I made it bigger until it felt too big
and then I narrowed it), I think we have now increased the space from
10dp to 25dp. I added the space by wrapping the buttons in
LinearLayouts, because they are <include>s and could theoretically
insert more than one button.

Concerns: If the "next"/"prev" buttons ever become active, the space
will not be correctly applied to those. We can fix that if it ever comes
up (we don't display those buttons). If people think the buttons should
be placed even further apart we can do this by just increasing the
number in styles.xml.

This is what it looks like now. See previous look and comparison with
23.0 in #4071

<img width=400
src="https://files.mastodon.social/media_attachments/files/111/293/547/524/867/101/original/91b83e1717111444.png">
2023-11-01 09:21:43 +01:00
Levi Bard 0f0ad6d5b0
Translations update from Weblate (#4088)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky
description](https://weblate.tusky.app/projects/tusky/tusky-app/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widget/tusky/tusky-app/horizontal-auto.svg)
2023-11-01 09:20:10 +01:00
Levi Bard 26220dc22b
Translations update from Weblate (#4087)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widget/tusky/tusky/horizontal-auto.svg)
2023-11-01 08:40:39 +01:00
Ümit Solmaz 7e2b7b4f93 Translated using Weblate (Turkish)
Currently translated at 100.0% (31 of 31 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/tr/
2023-11-01 07:14:36 +00:00
XoseM f7b821acf1 Translated using Weblate (Galician)
Currently translated at 100.0% (632 of 632 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-11-01 04:45:00 +00:00
Ümit Solmaz c9bc6c3fdf Translated using Weblate (Turkish)
Currently translated at 100.0% (632 of 632 strings)

Co-authored-by: Ümit Solmaz <usnotv@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-11-01 04:45:00 +00:00
Levi Bard 0301bed0ac
Fix showing filtered (warn) boosts in timelines (#4079) 2023-10-30 08:42:16 +01:00
Levi Bard c26abaea5d
Translations update from Weblate (#4080)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widget/tusky/tusky/horizontal-auto.svg)
2023-10-30 08:41:52 +01:00
Eric b870088050 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (632 of 632 strings)

Co-authored-by: Eric <ekhfcxwuvxqfdb@hldrive.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-10-30 04:45:00 +00:00
puf 685c51db53 Translated using Weblate (Welsh)
Currently translated at 100.0% (632 of 632 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-10-30 04:45:00 +00:00
Levi Bard 55ed6841ff
Support "replies policy" for lists (#4072) 2023-10-26 11:21:04 +02:00
mcclure 0f1d63e3c3
Upgrade all exoplayer/media3 packages to 1.1.1 stable (#4076)
Works in my testing
2023-10-25 13:45:33 -04:00
mcc a7cf5fcbc7 Upgrade all media3 packages to 1.1.1 stable 2023-10-25 11:59:46 -04:00
Levi Bard 70c67b9003
Translations update from Weblate (#4074)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widget/tusky/tusky/horizontal-auto.svg)
2023-10-25 13:24:40 +02:00
Levi Bard 131ebabe85
Add support for v2/instance (#4062)
…with fallback to v1
2023-10-25 12:53:10 +02:00
Markus Unterwaditzer 6e6cf05d11
Remove garbage alt text from images attached via Gboard (#4068)
Steps to reproduce:

1. install Gboard

(https://play.google.com/store/apps/details?id=com.google.android.inputmethod.latin)

2. open a direct link to any image in Firefox
3. long-press the image to get a "Copy Image" dialogue (and copy the
   image)
4. compose a new post in Tusky
5. Gboard will suggest to paste the image from clipboard
6. paste image, see that when opening alt text editor, it is filled out
   with this garbage string

Why is this bad? It's not when I just fix the alt text. But it breaks
every mechanism that is supposed to remind me of adding alt text.

It's hard to argue that this is within scope of Tusky but I also don't
see it getting fixed in Gboard, so here we go.
2023-10-25 12:52:42 +02:00
Weblate 99e16e9918 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/
Translation: Tusky/Tusky
2023-10-25 10:27:37 +00:00
UlrichKu 73ce9ffda5
4063: Make dialog size more stable (#4066)
Fixes: #4063

Switching from an AlertDialog to only a DialogFragment.

I didn't get the AlertDialog to be sized correctly.
It also opens now directly with the right (full screen) size. When the
imageView fails to load (i.e. with an audio file) it will be hidden.

This changes the button layout somewhat.

One observation: The placeholder text "... visually impaired..." is not
quite right as a description for an audio file is not intended for the
visually impaired. But I couldn't think of a better text just yet.


![grafik](https://github.com/tuskyapp/Tusky/assets/1618905/fd49d5bd-b86c-4659-abb9-f1776cbb2a55)
2023-10-25 11:59:59 +02:00
Levi Bard dd250717b2
Translations update from Weblate (#4064)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widgets/tusky/-/tusky/horizontal-auto.svg)
2023-10-25 11:59:15 +02:00
Weblate 85af732ec6 Added translation using Weblate (French (Belgium))
Added translation using Weblate (Telugu)

Added translation using Weblate (Punjabi)

Added translation using Weblate (Luxembourgish)

Added translation using Weblate (English (Australia))

Co-authored-by: Weblate <noreply@weblate.org>
2023-10-25 04:45:14 +00:00
puf eb849194e0 Translated using Weblate (Welsh)
Currently translated at 100.0% (629 of 629 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-10-25 04:45:14 +00:00
mcclure 1a10d5fd7a
Customize exoplayer to remove dark "curtain" (#4071)
In the new Tusky version, we switched from "old and busted" Android
video player to new "Exoplayer" (PR #3857). This introduced a dark
"curtain" covering the entire screen. This patch restores the 23.0 look.

This is done by creating a "magic" exo_player_control_view.xml override file.
2023-10-24 12:56:43 -04:00
mcc 98c3946ad9 Put semitransparent curtain back behind controls. This requires moving the stanza up in the file. 2023-10-24 12:32:26 -04:00
mcc 7590456dc1 Hide dark layer behind exoplayer controls 2023-10-23 17:15:47 -04:00
mcc 04ae4800ad Custom exo_player_control_view.xml file 2023-10-23 17:09:40 -04:00
UlrichKu b286255630
3532: Show badge on conversations tab on new conversations (#3890)
Fixes #3532

(Old PR, now closed: https://github.com/tuskyapp/Tusky/pull/3533)

Listens on new notifications and if a "direct mention" is detected a
badge (red dot) is shown on the conversations tab if present.

I am missing things like this a lot and also big accounts are unhappy
with the usability so far:
https://mastodon.social/@pallenberg/110129889996182814
2023-10-15 21:39:38 +02:00
Konrad Pozniak ff1c4a4b27
fix theme preference defaults (#4061)
closes #4060 

Also I noticed that we had the theme defaults twice in the code so I
refactored a bit so only one are still in there.
2023-10-14 14:20:20 +02:00
UlrichKu 8935b554d7
2512: Fix random tab colors (#4058)
Tries to fix the random colors for tabs on the bottom after #4051.

Does not work yet: The "selected" color does not take effect.
2023-10-11 23:11:49 +02:00
Lakoja 948d3b30cf 2512: Set style properly 2023-10-11 21:00:59 +02:00
Lakoja 7841209286 2512: Some in-vain efforts to fix wrong tab color 2023-10-11 19:48:21 +02:00
Levi Bard a39d210692
Translations update from Weblate (#4057)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widgets/tusky/-/tusky/horizontal-auto.svg)
2023-10-11 10:52:42 +02:00
UlrichKu 81a04c8977
2512: Allow more than 5 tabs (#4051)
Fixes #2512 


![grafik](https://github.com/tuskyapp/Tusky/assets/1618905/f8199d10-e26a-4f14-93c3-95cb890ea663)

Can add an arbitrary number of tabs.
Graphical behavior is unchanged for small numbers: the whole space if
filled with the tabs - they are enlarged if needed.

If there are more the mode switches to "scrollable".
This does not, however, look very differently (see screenshot with the
current tab scrolled out).

---------

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
2023-10-11 09:54:01 +02:00
Levi Bard 1f8bf728db
Translations update from Weblate (#4056)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widgets/tusky/-/tusky/horizontal-auto.svg)
2023-10-11 09:53:28 +02:00
Levi Bard b634f22efb
Translations update from Weblate (#4055)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky
description](https://weblate.tusky.app/projects/tusky/tusky-app/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widgets/tusky/-/tusky-app/horizontal-auto.svg)
2023-10-11 09:33:23 +02:00
Weblate eb61ab4900 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/
Translation: Tusky/Tusky
2023-10-11 07:29:36 +00:00
Ümit Solmaz a3081bff5a Translated using Weblate (Turkish)
Currently translated at 100.0% (630 of 630 strings)

Co-authored-by: Ümit Solmaz <usnotv@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-10-11 07:08:57 +00:00
Deleted User 5ff70923b7 Translated using Weblate (German)
Currently translated at 100.0% (630 of 630 strings)

Co-authored-by: Deleted User <noreply+304@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-10-11 07:08:57 +00:00
sanao 3f7480fb73
Displayed the date of each announcement. (#4041)
## Issue
Close #3967 

# What I did
- Displayed the date of each announcement.
  - Date is placed in the lower left corner of the Announcement
- Supported date format internationalization using
getBestDateTimePattern

# Screenshot
<image
src="https://github.com/tuskyapp/Tusky/assets/62137820/7c124183-1a13-4cae-8667-ff82ca99b60c"
width="500"/>

## Note
I am not good at English so I use machine translation a bit. So, you may
find my writing style a little strange...
2023-10-10 19:17:06 +02:00
Ümit Solmaz 33eed2177d Translated using Weblate (Turkish)
Currently translated at 100.0% (31 of 31 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/tr/
2023-10-10 16:52:51 +00:00
Konrad Pozniak 150d3854bc
don't cut off names on profiles (#4052)
We cut them off everywhere but there should be at least one place where
one can read full display/usernames

closes #4000 

<img
src="https://github.com/tuskyapp/Tusky/assets/10157047/21d22eb3-a6fc-4397-bdd0-551a3cce12d5"
width="400"/>
2023-10-09 15:43:02 +02:00
Levi Bard 119b14cb7f
Translations update from Weblate (#4050)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widgets/tusky/-/tusky/horizontal-auto.svg)
2023-10-09 15:42:37 +02:00
Salif Mehmed ef3bd4f919 Translated using Weblate (Bulgarian)
Currently translated at 87.4% (551 of 630 strings)

Co-authored-by: Salif Mehmed <mail@salif.eu>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/bg/
Translation: Tusky/Tusky
2023-10-07 15:40:54 +00:00
puf ab64525f4c Translated using Weblate (Welsh)
Currently translated at 99.8% (629 of 630 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-10-07 15:40:54 +00:00
Rhys Wynne 7b0b3d8f0d Translated using Weblate (Welsh)
Currently translated at 99.8% (629 of 630 strings)

Co-authored-by: Rhys Wynne <rhysw1@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-10-07 15:40:54 +00:00
Konrad Pozniak 9412ffba0f
fix boost button not updating when boosting (#4048) 2023-10-07 08:59:36 +02:00
Levi Bard 0fad9729ef
Translations update from Weblate (#4040)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widgets/tusky/-/tusky/horizontal-auto.svg)
2023-10-05 12:24:06 +02:00
Rhoslyn Prys 99cbd3a8f6 Translated using Weblate (Welsh)
Currently translated at 99.8% (629 of 630 strings)

Co-authored-by: Rhoslyn Prys <post@meddal.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-10-04 11:40:54 +00:00
Weblate 0efe314279 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/
Translation: Tusky/Tusky
2023-10-04 11:40:54 +00:00
Levi Bard 7036551b18
Fix weblate again (#4039)
Don't squash&merge or weblate won't pick it up :D
2023-09-29 07:16:05 +02:00
Conny Duck 74b391ce97 Merge remote-tracking branch 'weblate/develop' into fix-weblate 2023-09-28 20:05:08 +02:00
Levi Bard 1badf68531
Refresh timelines when filters are added/edited/removed (#3552)
Fixes #3546
2023-09-28 20:01:44 +02:00
Konrad Pozniak 14d4934172
Fix weblate conflict (#4038)
Co-authored-by: Eric <alchemillatruth@purelymail.com>
Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Co-authored-by: Manuel <mannivuwiki@gmail.com>
Co-authored-by: xzFantom <xzfantom@gmail.com>
Co-authored-by: Yusuke Matsubara <whym@whym.org>
Co-authored-by: XoseM <xosem@disroot.org>
Co-authored-by: Deleted User <noreply+143@weblate.org>
Co-authored-by: Salif Mehmed <mail@salif.eu>
Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Co-authored-by: ButterflyOfFire <butterflyoffire@protonmail.com>
2023-09-27 19:39:23 +02:00
UlrichKu 24985f0206
#3570: Also provide a "direct" back with the hardware button (#3571)
Fixes #3570
2023-09-27 08:56:09 +02:00
UlrichKu 2dc27bca2a
use link icon instead of emoji when showing hidden urls (#4031)
Looks way better imho. Also closes #4028.

Before vs After:


![Screenshot_20230924_163714](https://github.com/tuskyapp/Tusky/assets/10157047/5b0b745a-4574-4e37-988e-b04997ac55f1)


![Screenshot_20230924_162657](https://github.com/tuskyapp/Tusky/assets/10157047/58a3482f-6afb-4b10-9891-f7a86af7f2bf)
2023-09-26 21:46:05 +02:00
alcapurrias 6c8cf0191d
Update contributing.md with payment policy link (#4037)
Updated to add the payment policy link and capitalized "API" where used
2023-09-26 20:17:18 +02:00
Levi Bard 2dceecb591
Fix build after logical conflict (#4036)
Ugh, I didn't notice that #3480 was affected by the notification
fragment rollback
2023-09-26 20:04:06 +02:00
ButterflyOfFire 29ebe191a6 Translated using Weblate (Arabic)
Currently translated at 93.5% (590 of 631 strings)

Co-authored-by: ButterflyOfFire <butterflyoffire@protonmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ar/
Translation: Tusky/Tusky
2023-09-26 17:53:58 +00:00
Sveinn í Felli 149f8e5e39 Translated using Weblate (Icelandic)
Currently translated at 100.0% (631 of 631 strings)

Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
Salif Mehmed 6681ed20a2 Translated using Weblate (Bulgarian)
Currently translated at 87.1% (550 of 631 strings)

Co-authored-by: Salif Mehmed <mail@salif.eu>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/bg/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
Deleted User 0240c61fcd Translated using Weblate (Japanese)
Currently translated at 93.5% (590 of 631 strings)

Co-authored-by: Deleted User <noreply+143@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ja/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
XoseM 6dcf05b089 Translated using Weblate (Galician)
Currently translated at 100.0% (631 of 631 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
Yusuke Matsubara bf3de32800 Translated using Weblate (Japanese)
Currently translated at 93.3% (589 of 631 strings)

Co-authored-by: Yusuke Matsubara <whym@whym.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ja/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
xzFantom 4bb4a56e0d Translated using Weblate (Belarusian)
Currently translated at 92.0% (581 of 631 strings)

Co-authored-by: xzFantom <xzfantom@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
Manuel 563cd95db4 Translated using Weblate (Italian)
Currently translated at 100.0% (631 of 631 strings)

Co-authored-by: Manuel <mannivuwiki@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
Hồ Nhất Duy 40cfaeeb18 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (631 of 631 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (631 of 631 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (630 of 630 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-09-26 17:53:57 +00:00
Ihor Hordiichuk d00a893853 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (631 of 631 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (630 of 630 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
Oliebol 1f2b032822 Translated using Weblate (Dutch)
Currently translated at 95.7% (603 of 630 strings)

Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nl/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
Danial Behzadi 76786d9e59 Translated using Weblate (Persian)
Currently translated at 100.0% (631 of 631 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (630 of 630 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
Eric 0a80834e7d Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (631 of 631 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (630 of 630 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (629 of 629 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-09-26 17:53:57 +00:00
Konrad Pozniak 54e92b2156
improve local status updates (#3480)
The idea here is: Everytime we get hold of a new version of a post, we
update everything about that post everywhere.
This makes the distincion between different event types unnecessary, as
everythng is just a `StatusChangedEvent`.
The main benefit is that posts should be up-to-date more often, which is
important considering there is now editing and #3413
2023-09-26 09:08:58 +02:00
alcapurrias 2f39f87cc7
Payment Policy Document (#4035)
Document copied from CryptPad as approved by the team in the 2023-09-25
meeting.
2023-09-26 08:55:28 +02:00
Konrad Pozniak fbd99717c0
move Release.md to doc folder, remove ViewModelInterface.md (#4034) 2023-09-26 08:54:16 +02:00
Konrad Pozniak 5764c903e1
add role badges (#4029)
This adds support for the new Mastodon 4.2 role badges. Admins can
define if a role should be visible in the interface and then we get it
delivered by the Api on the `Account` object like this:
```
"roles": [
        {
              "id": "4",
              "name": "TEST",
              "color": "#ffee00"
        }
  ]
```

- keeps compatibility with older Mastodon version and non Mastodon
servers
- Took me a while, but I figured out a way to use the color and have it
look ok on all backgrounds (Mastodon itself ignores the color and just
always uses its brand color)
- falls back to Tusky blue in case no color is configured
- I adjusted the "Follows you" and "Bot" badges so they match the new
badge style
- In case the "Follows you" and "Bot" badges are visible at the same
time, "Follows you" gets its own line and "Bot" goes into the same line
as the role badge.
- Will work even with a lot of role badges (right now users can only
have 1 role at once though)
- Will work even when the badges federate (right now they don't)

<img
src="https://github.com/tuskyapp/Tusky/assets/10157047/24cbe889-ae46-408e-bfa0-cf3fd3c24f74"
width="320" />
2023-09-25 09:47:27 +02:00
UlrichKu 82bc48c3ae
Avoid crash on notification worker (#4025)
Not quite sure why/when this happens - every stack trace is not our
code, but I do get an ClassNotFoundException for Notification$Type with
the new reverted code.

The notification fetching (worker) then stops/crashes so I never get an
Android notification.

It might have something to do with
https://github.com/tuskyapp/Tusky/pull/3732 ?
2023-09-25 09:44:01 +02:00
Conny Duck 31ad946e1a use link icon instead of emoji when showing hidden urls 2023-09-24 16:28:53 +02:00
Angelo Suzuki fa80a0123a
Add "Trending posts" (statuses) feed (#4007)
Add "Trending posts" (statuses) feed.

This feed is a good source of interesting accounts to follow and,
personally, a sort of "Front page of the Fediverse".

Since #3908 and #3910 (which would provide a more thorough, albeit
complex, access to trending things) won't get merged, I'd like to
address this missing feed by simply adding another tab/feed.

~~If desired, I can move the second commit (fixing lint) to another
PR.~~

## Screenshots
### Tab
<img
src="https://github.com/tuskyapp/Tusky/assets/1063155/6a71a97e-673e-44c7-b67d-9b1df0bed4f5"
width=320 /> <img
src="https://github.com/tuskyapp/Tusky/assets/1063155/9bf60b23-d2f3-4dd8-8af6-e96647b02121"
width=320 />

### Activity
<img
src="https://github.com/tuskyapp/Tusky/assets/1063155/4e07dea3-d97f-42c6-8551-492a3116fcfa"
width=320 /> <img
src="https://github.com/tuskyapp/Tusky/assets/1063155/ad00a134-d622-43f4-8305-84cfa7fed706"
width=320 />
2023-09-14 22:37:41 +02:00
Mylloon bb1868fd67
Add system black theme (#3957)
Close #1222
2023-09-13 11:14:20 +02:00
UlrichKu f63c662275
Add the special appbar for the notifications again (#4022) 2023-09-13 09:21:08 +02:00
Levi Bard f99cb6d1d5
Fix lint warnings (#4019)
Clears the baseline of issues in our code, and resolves most of the
straightforward warnings from the report
2023-09-13 09:20:53 +02:00
Konrad Pozniak 7dfc8790c7
update minSdk to 24, cleanup code (#4014)
closes https://github.com/tuskyapp/Tusky/issues/2607
redo of https://github.com/tuskyapp/Tusky/pull/3593
2023-09-12 19:25:45 +02:00
Konrad Pozniak 0768dcd374
Cleanup unused resources (#4013)
redo of https://github.com/tuskyapp/Tusky/pull/3599
2023-09-12 18:11:06 +02:00
Levi Bard 8fb8fc8f02
Gradle 8.3 & AGP 8.1.1 (#3984) 2023-09-12 11:14:24 +02:00
Levi Bard c39769f6dd
Update Spanish translations (#4004)
Fix some typos and translations, hope it helps 👍🏻
2023-09-12 11:09:49 +02:00
Levi Bard 525adf3b81
Translations update from Weblate (#3998)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widgets/tusky/-/tusky/horizontal-auto.svg)
2023-09-12 11:07:33 +02:00
Levi Bard e5f0d00c9b
Translations update from Weblate (#3986)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky
description](https://weblate.tusky.app/projects/tusky/tusky-app/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widgets/tusky/-/tusky-app/horizontal-auto.svg)
2023-09-12 10:36:24 +02:00
Ümit Solmaz 210423da8b Translated using Weblate (Turkish)
Currently translated at 100.0% (628 of 628 strings)

Co-authored-by: Ümit Solmaz <usnotv@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
Sveinn í Felli 2ba0d60896 Translated using Weblate (Icelandic)
Currently translated at 100.0% (628 of 628 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.6% (626 of 628 strings)

Translated using Weblate (Icelandic)

Currently translated at 96.6% (607 of 628 strings)

Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
Ihor Hordiichuk e6481b5ac0 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (628 of 628 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
Eduardo 38b060f933 Translated using Weblate (Portuguese (Brazil))
Currently translated at 89.1% (560 of 628 strings)

Co-authored-by: Eduardo <edu200399lim@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pt_BR/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
XoseM d313bedcbc Translated using Weblate (Galician)
Currently translated at 100.0% (628 of 628 strings)

Translated using Weblate (Galician)

Currently translated at 100.0% (626 of 626 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
eira lindgren 8fbed6c864 Translated using Weblate (Swedish)
Currently translated at 99.6% (624 of 626 strings)

Co-authored-by: eira lindgren <newt@nonbinary.me>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sv/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
Hồ Nhất Duy 44875a0cbc Translated using Weblate (Vietnamese)
Currently translated at 100.0% (628 of 628 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (626 of 626 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (619 of 619 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-09-12 08:14:09 +00:00
Eric 120abbc63e Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (628 of 628 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (626 of 626 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (619 of 619 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
Oliebol 4266731bb0 Translated using Weblate (Dutch)
Currently translated at 97.4% (603 of 619 strings)

Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nl/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
Manuel dc7ae0bce5 Translated using Weblate (Italian)
Currently translated at 100.0% (619 of 619 strings)

Co-authored-by: Manuel <mannivuwiki@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
Danial Behzadi 1a032d2c70 Translated using Weblate (Persian)
Currently translated at 100.0% (628 of 628 strings)

Translated using Weblate (Persian)

Currently translated at 99.0% (620 of 626 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (619 of 619 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
puf 6380163de0 Translated using Weblate (Welsh)
Currently translated at 100.0% (619 of 619 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-09-12 08:14:09 +00:00
Levi Bard eaeb673e19
use PrefKeys everywhere (#4016) 2023-09-12 09:57:05 +02:00
Levi Bard 924db72380
update SparkButton to 4.2.0 (#4010)
I moved the library from Jitpack to Maven Central and changed the group
id (so renovate won't pick that up), other than that there is only a
dependency upgrade to `androidx.appcompat:appcompat:1.6.1` included
2023-09-12 09:53:15 +02:00
Levi Bard 765ee605c2
upgrade glide to 4.16.0 (#4017)
They added some nullability annotations to their Java code, which is
nice but requires some changes in our Kotlin code.
closes #3975
2023-09-12 09:52:05 +02:00
Levi Bard a525cab52b
Rollback aggressive linting (#4003)
- Revise linting rules
  - Make lint failures opt-in (for the project) instead of opt-out
  - Reduce noise
- Add explicit errors for things we would ask somebody to change in a
code review
  - Update baseline to only include the new errors
  - Remove baseline autoupdate task since:
    - We want this to happen very rarely
    - The autoupdater also adds warnings
- Remove reviewdog github action (that autoadds lint comments to PRs)
2023-09-12 09:46:59 +02:00
Levi Bard f7b2962f58
Resets the paging3 changes of 3159 back to the (java) fragment code (#4015) 2023-09-12 09:46:24 +02:00
XoseM da630b858b Translated using Weblate (Galician)
Currently translated at 74.1% (23 of 31 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/gl/
2023-09-12 07:34:51 +00:00
Lakoja 3ad8588019 Remove unneeded code 2023-09-11 22:41:57 +02:00
Lakoja 4f865ec95f Add a bunch of "old" methods to be able to still use java code 2023-09-11 22:19:34 +02:00
Lakoja 4af160853d Remove unneeded code 2023-09-11 21:58:56 +02:00
Conny Duck 42c033c5ae upgrade glide to 4.16.0 2023-09-11 19:29:50 +02:00
Conny Duck af5ec068e8 use PrefKeys everywhere 2023-09-11 19:12:33 +02:00
Conny Duck 4f9baf8e3e update SparkButton to 4.2.0 2023-09-10 10:05:11 +02:00
Lakoja add62129f8 Resets the paging3 changes of 3159 back to the (java) fragment code before.
Should be the basis for further not-so-rattling improvements.
2023-09-09 21:29:24 +02:00
Tak! 1887511b18 Update contributor documentation 2023-09-08 15:14:16 +02:00
Levi Bard 40d771d60f
Kotlin - Enable trailing comma (#3959)
Trailing commas on Kotlin sources [has many
advantages](https://pinterest.github.io/ktlint/0.49.0/rules/standard/#trailing-comma-on-call-site):
- It makes version-control diffs cleaner – as all the focus is on the
changed value.
- It makes it easy to add and reorder elements – there is no need to add
or delete the comma if you manipulate elements.
- It simplifies code generation, for example, for object initializers.
The last element can also have a comma.

This PR doesn't go as far as require it, but tweaks KtLint to at least
allow it.

The two `.kt` files prove that the KtLint rules have been properly
disabled.
2023-09-08 09:17:50 +02:00
Elena.NET 54be828d6c
Update Spanish translations 2023-09-07 17:20:55 +02:00
Tak! 1e553ebd8a Remove newLintBaseline task since we only want errors in the baseline 2023-09-07 14:20:30 +02:00
Tak! 26d963dda9 Make linting failures opt-in instead of opt-out 2023-09-07 14:20:30 +02:00
Tak! e416a3e43d Remove reviewdog github action 2023-09-07 10:18:52 +02:00
Levi Bard 84969586c8
refactor domain blocks to paging (#3801)
Adds Paging, ViewModel, error snackbars, removes Rx, renames everything
to "domain blocks" to match the Masto Api

part of #2992
2023-09-06 10:11:17 +02:00
Tak! a96460cb16 Merge branch 'develop' into refactor_instancemute 2023-09-06 08:55:34 +02:00
Levi Bard b0150212c3
3891 better hashtag filtering (#3893)
Fixes #3891 (the first two observerations; the rest is either ok or has
another issue)
2023-09-05 09:35:33 +02:00
Levi Bard 5f0f4b82b9
3530: Provide a help text on the empty conversations view (#3531)
Fixes #3530
2023-09-05 09:34:50 +02:00
Levi Bard 7016fa3abc
don't crash on unexpected json responses (#3635) 2023-09-05 09:34:20 +02:00
Levi Bard 02404564e0
Detect Bookwyrm URL formats (#3936)
While helping test an issue with
[Bookwyrm](https://github.com/bookwyrm-social/bookwyrm) I noticed that
the URL formats used by that project aren't checked as possible profile
or post links. They're quite close to a couple of others, so I just
copied close examples and edited a couple of terms.

It's pretty minor, I just used a previous commit as a reference. Let me
know if it needs anything more though. I've only quickly tested it on a
local build with a couple of links against a live Bookwyrm and it picks
them up as expected now.
2023-09-05 09:33:51 +02:00
Levi Bard 710ce32a02
fix links sometimes being underlined (#3990)
closes #3989 

before:

![before](https://github.com/tuskyapp/Tusky/assets/10157047/a9004b44-dfd3-414d-838e-538df1eee2a7)

after:

![after](https://github.com/tuskyapp/Tusky/assets/10157047/f98790ac-80f1-459a-b726-b14b112fe0b1)
2023-09-05 09:33:39 +02:00
Levi Bard a6ab185790
Fix weblate conflict (#3996)
Here this should fix the weblate conflict. No idea why this happened.
This PR must not be squashed or Weblate won't find its commits (as for
all Weblate PRs).
2023-09-04 11:38:08 +02:00
Conny Duck 27a8683627 fix weblate conflict 2023-09-04 11:20:37 +02:00
mcclure 6566f89997
Fix link to Matrix in README (#3995)
Updated Matrix chat link to correct Matrix Space link.
2023-09-03 16:02:08 +01:00
Conny Duck ee4f6f4b7f fix links sometimes being underlined 2023-08-30 18:49:59 +02:00
Goooler 1e9bb3a980
Merge branch 'tuskyapp:develop' into agp811 2023-08-29 19:53:44 +08:00
Levi Bard 10a6b1616a
Editing profile: No change warning on leave. (#3972)
### Objective
* Prevent data loss when the user inadvertently hits back or wants to
leave the profile edition with unsaved changes.

### Description
* To limit the number of changes to the existing codebase, I merely
re-used the same method used by `save()` in the ViewModel to decide
whether to make a network request or simply return the profile as-is.
* ~A bit of code juggling around in the ViewModel and I was able to use
the logic for all the encoding of each profile field (Which is what the
ViewModel caches in memory).~ Thanks @Lakoja for improving this in the
VM.
* A couple of internal data classes used as helpers to move all the
fields around (now that they are no longer used in one single place)
were introduced.

### Potential Optimizations
* ~The profile encoding is done twice (once for checking, and then again
if the user has to actually save it). I'd say this is a negligible price
to pay, since the alternative would be to create a different set of
comparisons and/or keeping another profile in memory for the purpose of
comparison.~

### Visual Improvement
* I believe the Dialog is difficult to see, but it's being displayed
with Tusky's theme. Perhaps there's a better style to apply in this
case? (or maybe the edit profile activity shouldn't have the same
background color as dialogs?!)


### Issue
* #3486
2023-08-28 14:13:24 +02:00
Goooler 952e83959a Remove lint version overriding 2023-08-25 10:27:14 +08:00
Goooler 0b67be9932 Use configureEach 2023-08-25 10:22:32 +08:00
Goooler 268a689f3d Remove compileOptions 2023-08-25 10:21:47 +08:00
Goooler 6a829da010 AGP 8.1.1 2023-08-25 10:21:27 +08:00
Goooler d694f8f495 Migrate deprecated buildDir 2023-08-25 10:20:18 +08:00
Goooler 4c853a8689 Gradle 8.3 2023-08-25 10:19:40 +08:00
Mike Haynes 5764efa5d4
Allow the user to add a "bookmarks" tab (#3983)
Fixes #2368
2023-08-24 16:21:43 +02:00
Martin Marconcini 780dae00a6
Merge branch 'develop' into prompt_to_save_before_leaving_changed_profile 2023-08-24 15:14:10 +02:00
Lakoja 70d86345a8 3891: Match (only) title for muting state 2023-08-24 15:00:52 +02:00
Nik Clayton f118088161
Revert "Enable gradle build cache for Bitrise builds" (#3920)
Reverts tuskyapp/Tusky#3840. Turns out this needs to be enabled at
Bitrise, and it's not on our plan. So every build is running this extra
workflow, but it's not providing any benefit, just slowing things down.
2023-08-24 13:01:03 +02:00
Martin Marconcini b0294a8b93
Merge branch 'develop' into prompt_to_save_before_leaving_changed_profile 2023-08-24 10:06:43 +02:00
Martin Marconcini 387c3989d7
Apply PR suggestions:
* Remove dialog title and string
* Rename Data Class
* Use liveData.value directly when possible
2023-08-24 10:06:25 +02:00
Nik Clayton 0c892cf56f
Leave comments on PRs with fixes for lint errors (#3224)
Runs `ktlintFormat`, and adds comments to the PR if that generates any
diffs. The comments include the fix, which can be accepted immediately
through the GitHub UI.
2023-08-23 22:49:44 +02:00
Martin Marconcini 2d52ab9072
Merge branch 'develop' into prompt_to_save_before_leaving_changed_profile 2023-08-23 15:50:43 +02:00
Martin Marconcini a58f29fa57
Merge pull request #1 from Lakoja/3486-separate-diff-from-encode
3486 separate diff from encode
2023-08-23 15:50:26 +02:00
Lakoja 45d2fa1570 3486: (Appease linter) 2023-08-23 15:06:08 +02:00
SpaceFox 85888ac238
Uses the system theme as default theme (#3813)
Set the "System Design" as the default theme.

This ensures that the app's initial behaviour respect's the user's system-wide theme choice, while still allowing the user to adjust it later.

This is only done for new installs of Tusky. If the user is upgrading from a previous release and they did not have an explicit theme set then the dark theme is used, and the UX does not change.
2023-08-23 15:04:24 +02:00
Weblate dd0cf9c366
Translations update from Weblate (#3971)
Translations update from [Weblate](https://weblate.tusky.app) for
[Tusky/Tusky](https://weblate.tusky.app/projects/tusky/tusky/).



Current translation status:

![Weblate translation
status](https://weblate.tusky.app/widgets/tusky/-/tusky/horizontal-auto.svg)

---------

Co-authored-by: ButterflyOfFire <butterflyoffire@protonmail.com>
Co-authored-by: puf <puffinux@tutanota.com>
Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Co-authored-by: Eric <alchemillatruth@purelymail.com>
Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Co-authored-by: XoseM <xosem@disroot.org>
Co-authored-by: Nik Clayton <nik@ngo.org.uk>
2023-08-22 22:23:07 +02:00
Lakoja 3a40274003 3486: Re-introduce separate check method to not need a synthetic accessor (lint error) 2023-08-22 21:17:22 +02:00
Nik Clayton f49b1cc744
Fix exception when updating summary notifications (#3976)
dc9e9f2aeb
modifed the code that fetched the value of EXTRA_NOTIFICATION_TYPE in an
intent, to use getSerializable().

However, the value was being placed in to the intent using putString().

This caused an exception when trying to update the summary notification,
so it would never update.

```
java.lang.ClassCastException: java.lang.String cannot be cast to com.keylesspalace.tusky.entity.Notification$Type
    at com.keylesspalace.tusky.components.notifications.NotificationHelper.updateSummaryNotifications(NotificationHelper.java:321)
    at com.keylesspalace.tusky.components.notifications.NotificationFetcher.fetchAndShow(NotificationFetcher.kt:87)
    at com.keylesspalace.tusky.components.notifications.NotificationFetcher$fetchAndShow$1.invokeSuspend(Unknown Source:14)
```

Fix this by placing the value in to the intent using putSerializable(),
to match how it will be fetched.
2023-08-22 18:22:45 +02:00
Lakoja ba50ff5686 3486: Separate diff and encoding 2023-08-22 16:12:03 +02:00
Lakoja f09f464667 3486: Rename stuff 2023-08-22 15:52:09 +02:00
Martin Marconcini 84915e6af5
Merge branch 'develop' into prompt_to_save_before_leaving_changed_profile 2023-08-22 13:06:27 +02:00
Martin Marconcini e56c0cb5a3
Avoid synthetic accessors. 2023-08-22 12:49:33 +02:00
Martin Marconcini 8edc8d6422
Make profileData internal so there's no synthetic accessor required. 2023-08-22 12:19:38 +02:00
Martin Marconcini c446d510e4
Fix lint double space. 2023-08-22 12:08:13 +02:00
Martin Marconcini 06239bb8a1
Fix synthetic accessor lint error. 2023-08-22 12:05:11 +02:00
Martin Marconcini 634f020ffa
Apply klint recommendations. 2023-08-19 17:57:25 +02:00
Martin Marconcini 461ec8d722
Prompt user before leaving edit profile when any field has been modified. 2023-08-19 17:36:00 +02:00
Nik Clayton 059352f471
Display notification filter/clear actions as menu items (#3877)
Previously the notification filter and clear actions were shown as
buttons in the UI, with a preference that determined whether they were
displayed.

Remove this preference, and display them as menu items.

- "Filter notifications" is shown as an icon, if possible
- "Clear notifications" is only ever shown as a menu item, to reduce the
chance the user inadvertently selects it

To ensure that the options menu appears correctly, remove the code that
creates a "fake" action bar, and adjust the layouts so that there are
three toolbars;

- mainToolbar -- displays the icons, and the current "location" (Home,
Notifications, etc)
- topNav -- displays the row of tabs at the top
- bottomNav -- displays the row of tabs at the bottom

Only one of them is set as the support action bar (depending on the
user's preferences). This provides the "show a logo" and "show the
options menu" functionality as standard, without needing to re-implement
as the previous code did.
2023-08-19 14:41:10 +02:00
Angelo Suzuki c7ffc6ad93
Hide option to mute own domain from account profile page (it is a no-op). (#3937)
Muting your own server domain is a no-op. Check for this case, and remove the "mute" menu item if true.

Fixes #3798
2023-08-19 14:17:04 +02:00
XoseM 02b0bdb266 Translated using Weblate (Galician)
Currently translated at 99.8% (618 of 619 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-08-19 11:56:38 +00:00
Hồ Nhất Duy e08582ded1 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (619 of 619 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-08-19 11:56:38 +00:00
Eric 2d9d472464 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (619 of 619 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-08-19 11:56:38 +00:00
Oliebol 25d7bfab5c Translated using Weblate (Dutch)
Currently translated at 96.9% (600 of 619 strings)

Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nl/
Translation: Tusky/Tusky
2023-08-19 11:56:38 +00:00
Danial Behzadi e53a0f0644 Translated using Weblate (Persian)
Currently translated at 100.0% (619 of 619 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-08-19 11:56:38 +00:00
puf 8eefdb56e2 Translated using Weblate (Welsh)
Currently translated at 100.0% (619 of 619 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-08-19 11:56:38 +00:00
ButterflyOfFire 106410d04b Translated using Weblate (French)
Currently translated at 96.2% (594 of 617 strings)

Co-authored-by: ButterflyOfFire <butterflyoffire@protonmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fr/
Translation: Tusky/Tusky
2023-08-19 11:56:38 +00:00
Nik Clayton b6102a755a
Rename "Trending" to "TrendingTags" or similar where necessary (#3906)
The "trending" functionality will expand to include trending links and
posts. But at the moment the "Trending" references in the code are
exclusively to hashtags.

Rename "Trending" to "TrendingTags" or similar everywhere necessary in
order to prepare for this.

This includes a database migration, as the identifier for the "Trending
tags" tab in the account preferences was changed from "Trending" to
"TrendingTags". The migration updates the stored value if necessary.
2023-08-19 12:54:35 +02:00
Konrad Pozniak dc9e9f2aeb
Improve account switching intents (#3732)
Before, intent creation was scattered across multiple sites, with account switching logic in both `ComposeActivity` and `MainActivity`.

Now, intents are only created in `MainActivity` Companion, and account switching only occurs in `MainActivity`

Fixes #3695
2023-08-19 12:31:47 +02:00
Martin Marconcini 85f0b1f320
Prompt the user before deleting a filter
Prevent users from accidentally deleting filters by prompting them to confirm.

Add an AlertDialog extension that converts AlertDialog callbacks to linear control flow.

Fixes #3736.
2023-08-17 22:26:46 +02:00
puf 22b7afb62d Translated using Weblate (Welsh)
Currently translated at 100.0% (617 of 617 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-08-17 17:12:22 +02:00
Angelo Suzuki cf42b687e6 Kotlin - Enable trailing comma 2023-08-16 20:28:19 +02:00
Lakoja 35fd702472 3891: Remove unnecessary if (use "this.hashtag" consistently) 2023-08-15 09:09:59 +02:00
Lakoja 6bd1c234d2 3891: Change length to short consistently 2023-08-15 09:09:59 +02:00
Lakoja 96028c841a 3891: (use string templates) 2023-08-15 09:09:59 +02:00
Lakoja e5456b0197 3891: Directly start the edit filter activity 2023-08-15 09:09:59 +02:00
Lakoja a883ec71d5 3891: Only mute the actual hashtag (with #) 2023-08-15 09:09:58 +02:00
Lakoja b0a50f9f50 3891: Add feedback (snackbars) for hashtag actions 2023-08-15 09:09:58 +02:00
Ümit Solmaz 64264ead68 Translated using Weblate (Turkish)
Currently translated at 100.0% (617 of 617 strings)

Co-authored-by: Ümit Solmaz <usnotv@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-08-14 12:00:59 +02:00
Hồ Nhất Duy 2e25431f40 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (617 of 617 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (617 of 617 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-08-14 12:00:59 +02:00
Nik Clayton 58521b3370
Remove unnecessary log message in CompositeWithOpaqueBackground (#3955) 2023-08-14 12:00:21 +02:00
Hồ Nhất Duy 1275a35ebf Translated using Weblate (Vietnamese)
Currently translated at 100.0% (617 of 617 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-08-13 23:35:59 +02:00
S. Lee 452e305464 Translated using Weblate (Korean)
Currently translated at 57.0% (352 of 617 strings)

Co-authored-by: S. Lee <ssharp0614@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ko/
Translation: Tusky/Tusky
2023-08-13 23:35:59 +02:00
Eric 4cc97b1c5d Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (617 of 617 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-08-13 23:35:59 +02:00
Oliebol bd141983f2 Translated using Weblate (Dutch)
Currently translated at 96.9% (598 of 617 strings)

Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nl/
Translation: Tusky/Tusky
2023-08-13 23:35:59 +02:00
Danial Behzadi 0592de0530 Translated using Weblate (Persian)
Currently translated at 100.0% (617 of 617 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-08-13 23:35:59 +02:00
puf 63848291b6 Translated using Weblate (Welsh)
Currently translated at 100.0% (617 of 617 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-08-13 23:35:59 +02:00
renovate[bot] 13399d8737
Update dependency org.mockito.kotlin:mockito-kotlin to v5.1.0 (#3947)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
|
[org.mockito.kotlin:mockito-kotlin](https://togithub.com/mockito/mockito-kotlin)
| `5.0.0` -> `5.1.0` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/org.mockito.kotlin:mockito-kotlin/5.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/org.mockito.kotlin:mockito-kotlin/5.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/org.mockito.kotlin:mockito-kotlin/5.0.0/5.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.mockito.kotlin:mockito-kotlin/5.0.0/5.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>mockito/mockito-kotlin
(org.mockito.kotlin:mockito-kotlin)</summary>

###
[`v5.1.0`](https://togithub.com/mockito/mockito-kotlin/releases/tag/5.1.0)

[Compare
Source](https://togithub.com/mockito/mockito-kotlin/compare/5.0.0...5.1.0)

<sup><sup>*Changelog generated by [Shipkit Changelog Gradle
Plugin](https://togithub.com/shipkit/shipkit-changelog)*</sup></sup>

##### 5.1.0

- 2023-08-09 - [3
commit(s)](https://togithub.com/mockito/mockito-kotlin/compare/5.0.0...5.1.0)
by Hunter Werlla, Lukáš Křečan, Tim van der Lippe
- Also run release job on 5.x tags
[(#&#8203;491)](https://togithub.com/mockito/mockito-kotlin/pull/491)
- [#&#8203;474](https://togithub.com/mockito/mockito-kotlin/issues/474)
Fix vararg argument matcher
[(#&#8203;490)](https://togithub.com/mockito/mockito-kotlin/pull/490)
- Fix issue
[#&#8203;480](https://togithub.com/mockito/mockito-kotlin/issues/480) by
widening the allowed types for lenient().whenever()
[(#&#8203;485)](https://togithub.com/mockito/mockito-kotlin/pull/485)
- lenient().whenever() reports type mismatch TypeVariable(T) was
expected
[(#&#8203;480)](https://togithub.com/mockito/mockito-kotlin/issues/480)
- Mockito 5.0 breaks vararg argument capture in Kotlin
[(#&#8203;474)](https://togithub.com/mockito/mockito-kotlin/issues/474)

</details>

---

### 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 this update
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:eyJjcmVhdGVkSW5WZXIiOiIzNi40MC4zIiwidXBkYXRlZEluVmVyIjoiMzYuNDAuMyIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-13 23:35:19 +02:00
Nik Clayton 8529f309ff
Migrate to androidx-media3 video player (#3857)
Behaviour is consistent with previous player except that:

- Swapping apps while a video is playing, and then returning to Tusky,
will keep the seek position in the video instead of returning to the
start
- The controls/media description can be shown by tapping anywhere, not
just on the video itself
- The media description is on-screen for the same duration as the player
controls (5 seconds here, 3 seconds in the previous code)
- The user has options to control the playback speed
- Rotating the device does not squash/stretch the video
- Show the media preview when playing audio-only files

Fixes https://github.com/tuskyapp/Tusky/issues/3329,
https://github.com/tuskyapp/Tusky/issues/3141,
https://github.com/tuskyapp/Tusky/issues/3126,
https://github.com/tuskyapp/Tusky/issues/2753,
https://github.com/tuskyapp/Tusky/issues/3508,
https://github.com/tuskyapp/Tusky/issues/3291

---------

Co-authored-by: mcc <andi.m.mcclure@gmail.com>
2023-08-10 19:31:55 +02:00
Nik Clayton 09d4f62004
Start/end the thread divider in the middle of the avatar (#3892)
Previously, the thread indicator would start at the top of the avatar
for the status at the start of the thread, and end at the top of the
avatar for the status at the end of the thread.

If these avatars were partially transparent the thread indicator could
either (a) poke out of the top of the avatar at the start of the thread,
(b) not properly connect with the avatar at the end of the thread, or
(c) both.

Partially fix this by making the divider start/stop in the middle of the
avatar. This assumes that this area will typically have opaque content,
even if some of the rest of the avatar is transparent. This is not
always true, but it's still better than the current behaviour.
2023-08-10 19:25:33 +02:00
puf f5c7054b8c Translated using Weblate (Welsh)
Currently translated at 100.0% (31 of 31 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/cy/
2023-08-10 15:35:35 +02:00
GunChleoc b79adf3ba6 Translated using Weblate (Gaelic)
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: GunChleoc <fios@foramnagaidhlig.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/
Translation: Tusky/Tusky
2023-08-10 15:05:22 +02:00
Ihor Hordiichuk e719c38599 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-08-10 15:05:22 +02:00
puf a261450eb2 Translated using Weblate (Welsh)
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-08-10 15:05:22 +02:00
Nik Clayton 4169dc34c0
Composite semi-transparent avatars over a solid background (#3874)
Avatars that are semi-transparent are a problem when viewing a thread,
as the line that connects different statuses in the same thread is drawn
underneath the avatar and is visible.

Fix this with a CompositeWithOpaqueBackground Glide transformation that:

1. Extracts the alpha channel from the avatar image
2. Converts the alpha to a 1bpp mask
3. Draws that mask on a new bitmap, with the appropriate background
colour
4. Draws the original bitmap on top of that

So any partially transparent areas of the original image are drawn over
a solid background colour, so anything drawn under them will not appear.
2023-08-08 23:09:59 +02:00
Angelo Suzuki bc310ca3fb
Add background to sensitive media button (#3939)
Using the same background and margin as the ALT button.

Fixes #3772
2023-08-08 23:08:35 +02:00
Nik Clayton 41951c906c
Provide specific gradle properties for builds on CI (#3924)
Expectation is that these will result in faster builds by disabling
options (like the gradle daemon) that benefit long-lived processes but
do not benefit ephemeral build workers.
2023-08-08 23:04:29 +02:00
Hồ Nhất Duy 1f3904b139 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (616 of 616 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-08-08 17:33:39 +02:00
Ümit Solmaz 58738c48f0 Translated using Weblate (Turkish)
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: Ümit Solmaz <usnotv@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-08-08 17:33:39 +02:00
Danial Behzadi d47f646fc2 Translated using Weblate (Persian)
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-08-08 17:33:39 +02:00
Deleted User 196b0949bd Translated using Weblate (German)
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: Deleted User <noreply+295@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-08-08 17:33:39 +02:00
puf 55af7fc364 Translated using Weblate (Welsh)
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-08-08 17:33:39 +02:00
Mike Barnes 5596e31769
Merge branch 'tuskyapp:develop' into bookwyrm-urls 2023-08-08 20:05:04 +10:00
Mike Barnes 83de22950d
Update LinkHelperTest.kt
Remove negative test for a URL that's invalid for Pleroma, but valid for Bookwyrm
2023-08-08 19:10:36 +10:00
renovate[bot] 468de28ac6
Update plugin ktlint to v11.5.1 (#3941)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| org.jlleitschuh.gradle.ktlint | `11.5.0` -> `11.5.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/org.jlleitschuh.gradle.ktlint/11.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/org.jlleitschuh.gradle.ktlint/11.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/org.jlleitschuh.gradle.ktlint/11.5.0/11.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jlleitschuh.gradle.ktlint/11.5.0/11.5.1?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 this update
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:eyJjcmVhdGVkSW5WZXIiOiIzNi4yNy4xIiwidXBkYXRlZEluVmVyIjoiMzYuMjcuMSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-08 01:56:00 +02:00
Angelo Suzuki b88f1d810b
Make CW description selectable (#3926)
While the status text in the detailed layout was selectable, CW
description wasn't.

Fixes #3826.
2023-08-07 20:32:17 +02:00
Nik Clayton 791092ef13
Fix extra clicks on media tab (#3930)
If:

1. You're viewing an account's media tab
2. Some of the media was marked sensitivei
3. The `alwaysShowSensitiveMedia` setting was `true`

tapping on the image (once) would do nothing visible, because it was
treated as the "reveal sensitive media" tap. You had to tap on it a
second time to open it.

Fix this, by passing the preference value through to the relevant code.

---------

Co-authored-by: Tiga! <maxiinne@proton.me>
2023-08-07 19:30:56 +02:00
Nik Clayton 498c7646b0
Add `newLintBaseline` task (#3925)
Add a task to regenerate the lint baseline.
2023-08-07 19:30:21 +02:00
Nik Clayton 5d4c14aed9
Correct the calculations for choosing the earliest day to show in the calendar / selected day (#3923)
To determine the earliest day to show in the calendar, take the current
date/time, add the minimum scheduled seconds buffer (which may roll the
date/time over to the next day), and then clamp to the start of that
day. So it's either today (if the current time + minimum scheduled
seconds is less than midnight) or it's tomorrow.

When displaying the calendar work around a misfeature in Material Date
Picker. It accepts UTC seconds-since-epoch, but does not convert it to
the local time for display.

While I'm here, show the selected day in the time picker's title.

Fixes https://github.com/tuskyapp/Tusky/issues/3916
2023-08-07 19:30:08 +02:00
Mike Barnes e290df5499
Update LinkHelperTest.kt
Add test URLs for Bookwyrm
2023-08-07 19:43:03 +10:00
Mike Barnes 28fd9911b0
Update LinkHelper.kt
Add URL formats used by Bookwyrm
2023-08-07 19:39:54 +10:00
Lakoja 8de23d33b8 3530: Change (remove) quotes 2023-08-07 09:44:58 +02:00
Lakoja 7347ab96dd 3530: Hide help text if the message view is reconfigured (ie an error occurred). 2023-08-07 09:44:58 +02:00
Lakoja 0d1340781b 3530: Add help text for empty lists view. 2023-08-07 09:44:58 +02:00
Lakoja c9f59aace4 3530: Provide a help text on the empty conversations view 2023-08-07 09:44:58 +02:00
Nik Clayton 846289b8cc
Add support for setting a list's "exclusivity" option (#3932)
Fixes #3831
2023-08-06 20:29:34 +02:00
Mike Barnes 6766509977
Use case-insensitive checks when comparing account URLs in search (#3931)
Fixes #3929
2023-08-06 13:45:48 +02:00
Angelo Suzuki a84d7f56a0
Filters> Tabs: use checkbox instead of switch (#3927)
Nowhere else in the preferences are checkboxes used.

Also removed the now unused extension function.

Fixes #3535
2023-08-06 11:50:11 +02:00
Hồ Nhất Duy ef8afe02b6 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (616 of 616 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-08-06 11:48:49 +02:00
Eric bfe907f4f6 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-08-06 11:48:49 +02:00
Quentí 48a88dea9e Translated using Weblate (Occitan)
Currently translated at 99.0% (610 of 616 strings)

Co-authored-by: Quentí <quentinantonin@free.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/
Translation: Tusky/Tusky
2023-08-06 11:48:49 +02:00
Danial Behzadi 51370cc22f Translated using Weblate (Persian)
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-08-06 11:48:49 +02:00
puf f3bbfa90fc Translated using Weblate (Welsh)
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-08-06 11:48:49 +02:00
UlrichKu cb8a09d3f7
Officially rename error elephant to errorphant (#3922)
In honor of
https://strangeobject.space/@esther/109866090382540100
https://masto.ai/@mynotaurus/110826349670549779
2023-08-05 15:39:07 +02:00
Nik Clayton 49fd2c8478
Populate gradle build cache on each push to develop (#3904)
The build cache is used in ci.yml to speed up builds, but it needs to be
populated by building code that's been merged.
2023-08-04 14:11:30 +02:00
Nik Clayton d5e2ce9608
Use colorPrimary for the "edit" schedule icon (#3917)
The "edit" icon when showing a scheduled status' time was grey, so it's
not obvious that this section is clickable.

Use colorPrimary, so it looks more like a button.
2023-08-04 13:37:31 +02:00
renovate[bot] 2e6ddbcbee
Update plugin google-ksp to v1.9.0-1.0.13 (#3913)
[![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.devtools.ksp](https://goo.gle/ksp)
([source](https://togithub.com/google/ksp)) | `1.9.0-1.0.12` ->
`1.9.0-1.0.13` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.devtools.ksp/1.9.0-1.0.13?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.google.devtools.ksp/1.9.0-1.0.13?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.google.devtools.ksp/1.9.0-1.0.12/1.9.0-1.0.13?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.devtools.ksp/1.9.0-1.0.12/1.9.0-1.0.13?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>google/ksp (com.google.devtools.ksp)</summary>

###
[`v1.9.0-1.0.13`](https://togithub.com/google/ksp/releases/tag/1.9.0-1.0.13)

[Compare
Source](https://togithub.com/google/ksp/compare/1.9.0-1.0.12...1.9.0-1.0.13)

##### Issues fixed

- Fixed an issue in KSP Gradle plugin where KMP source set is not set
correctly.
- Fixed an issue in incremental building where timestamp for generated
java sources are incorrect.

</details>

---

### 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 this update
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:eyJjcmVhdGVkSW5WZXIiOiIzNi4yNy4xIiwidXBkYXRlZEluVmVyIjoiMzYuMjcuMSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-04 13:08:04 +02:00
Nik Clayton f5b89f855f
Show the correct tab title when opening the activity (#3915)
The previous code used `notificationTabPosition`, which was never
changed, so always 0.

This meant that if you e.g., got to `MainActivity` by clicking on a
notification, and the notification tab was current, the title would
still show "Home".

Fix that by using the existing `position` variable which represents the
currently selected tab, and ensure the correct title is shown.

Fixes #3864.
2023-08-03 21:49:19 +02:00
Ümit Solmaz 79b637c97a Translated using Weblate (Turkish)
Currently translated at 100.0% (610 of 610 strings)

Co-authored-by: Ümit Solmaz <usnotv@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-08-03 14:30:32 +02:00
puf 1f18f3e786 Translated using Weblate (Welsh)
Currently translated at 100.0% (610 of 610 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-08-03 14:30:32 +02:00
Nik Clayton 9cda091d03
Show additional bug report info in AboutActivity (#3802)
Make it easier for people to find information we need for a bug report,
and show it on AboutActivity.

New info is:

- Device manufacturer (e.g., "Google") and model (e.g., "Pixel 4a (5G)")
- Android version (e.g., "13")
- SDK version (e.g., "33")
- Active account (e.g., "@Tusky@mastodon.social")
- Server's version (e.g., "4.1.2+nightly-20230627")

All info is copyable to make it easy to include in a bug report. A
button to copy the information is also shown.
2023-08-03 12:20:35 +02:00
renovate[bot] 561af5f105
Update dependency com.github.MikeOrtiz:TouchImageView to v3.5 (#3914)
[![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.github.MikeOrtiz:TouchImageView](https://togithub.com/MikeOrtiz/TouchImageView)
| `3.4` -> `3.5` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.github.MikeOrtiz:TouchImageView/3.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.github.MikeOrtiz:TouchImageView/3.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.github.MikeOrtiz:TouchImageView/3.4/3.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.github.MikeOrtiz:TouchImageView/3.4/3.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>MikeOrtiz/TouchImageView
(com.github.MikeOrtiz:TouchImageView)</summary>

###
[`v3.5`](https://togithub.com/MikeOrtiz/TouchImageView/releases/tag/3.5)

[Compare
Source](https://togithub.com/MikeOrtiz/TouchImageView/compare/3.4...3.5)

<!-- Release notes generated using configuration in .github/release.yml
at 3.5 -->

#### What's Changed

##### Exciting New Features 🎉

- Java 17 by [@&#8203;hannesa2](https://togithub.com/hannesa2) in
[https://github.com/MikeOrtiz/TouchImageView/pull/541](https://togithub.com/MikeOrtiz/TouchImageView/pull/541)
- Android Studio Flamingo by
[@&#8203;hannesa2](https://togithub.com/hannesa2) in
[https://github.com/MikeOrtiz/TouchImageView/pull/542](https://togithub.com/MikeOrtiz/TouchImageView/pull/542)
- Android Studio Giraffe by
[@&#8203;hannesa2](https://togithub.com/hannesa2) in
[https://github.com/MikeOrtiz/TouchImageView/pull/547](https://togithub.com/MikeOrtiz/TouchImageView/pull/547)

##### Other Changes

- Bump gradle/wrapper-validation-action from 1.0.6 to 1.1.0 by
[@&#8203;dependabot](https://togithub.com/dependabot) in
[https://github.com/MikeOrtiz/TouchImageView/pull/544](https://togithub.com/MikeOrtiz/TouchImageView/pull/544)

**Full Changelog**:
https://github.com/MikeOrtiz/TouchImageView/compare/3.4...3.5

</details>

---

### 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 this update
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:eyJjcmVhdGVkSW5WZXIiOiIzNi4yNy4xIiwidXBkYXRlZEluVmVyIjoiMzYuMjcuMSIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-03 12:19:50 +02:00
Goooler 40bd95d752
Kotlin 1.9.0 (#3835)
Update to Kotlin 1.9.0 and migrate to newer language idioms.

- Remove unnecessary @OptIn for features migrated to mainstream
- Use `data object` where appropriate
- Use new enum `entries` property
2023-08-02 09:04:24 +02:00
Suguru Hirahara 5391b5f797 Translated using Weblate (Japanese)
Currently translated at 97.7% (596 of 610 strings)

Co-authored-by: Suguru Hirahara <marveled_whitcomb@hostux.ninja>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ja/
Translation: Tusky/Tusky
2023-07-31 14:22:23 +02:00
Nik Clayton 79ee2dc32c
Fix image zoom / pan / scroll / swipe (#3894)
Migrate to touchimageview from photoview, and adjust the touch logic to correctly handle single finger drag, two finger pinch/stretch, flings, taps, and swipes.

As before, the features are:

- Single tap, show/hide controls and media description
- Double tap, zoom in/out
- Single finger drag up/down, scale/translate image, dismiss if scrolled too far
- Single finger drag left/right
  - When not zoomed, swipe to next image if multiple images present
- When zoomed, scroll to edge of image, then to next image if multiple images present
- Two finger pinch/zoom, zoom in/out on the image

Behaviour differences to previous code

1. Bug fix: The image can't get "stuck" when zoomed, and impossible to scroll
2. Bug fix: Pinching is not mis-interpreted as a fling, closing the image
3. Bug fix: The zoom state of images is not lost or misinterpreted when the user swipes through multiple images
4. Bug fix: Double-tap zooms all the way, instead of stopping
5. Tapping outside the image does not dismiss it, controls and description show/hide

Fixes https://github.com/tuskyapp/Tusky/issues/3562, https://github.com/tuskyapp/Tusky/issues/2297
2023-07-31 12:44:01 +02:00
Nik Clayton 30be3ce1f0
Use lint version 8.1.0 to fix erroneous warning about forEach (#3903)
Android lint was erroneously warning that the forEach construct in
Kotlin required API 24+, which is incorrect, see
https://issuetracker.google.com/issues/185418482.

Work around that by forcing the Android lint version to 8.1.0.

This triggered some additional checks, which have been ignored, and a
new baseline.
2023-07-30 21:45:43 +02:00
Nik Clayton 839d8bcc04
Migrate to AGP 8.0.2 / Android Studio Flamingo / Java 17 (#3541)
- Update AGP in version catalog to 8.0.2
- Set Java version to 17
- Enable non-final resource IDs
2023-07-30 15:50:04 +02:00
Connyduck 0f74a6b3dc Added translation using Weblate (Hebrew)
Co-authored-by: Connyduck <weblate@connyduck.at>
2023-07-30 15:30:24 +02:00
renovate[bot] 41fc480ef7
Update androidx.room to v2.5.2 (#3768)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
|
[androidx.room:room-testing](https://developer.android.com/jetpack/androidx/releases/room#2.5.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.5.1` -> `2.5.2` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.room:room-testing/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.room:room-testing/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.room:room-testing/2.5.1/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.room:room-testing/2.5.1/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[androidx.room:room-ktx](https://developer.android.com/jetpack/androidx/releases/room#2.5.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.5.1` -> `2.5.2` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.room:room-ktx/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.room:room-ktx/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.room:room-ktx/2.5.1/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.room:room-ktx/2.5.1/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[androidx.room:room-paging](https://developer.android.com/jetpack/androidx/releases/room#2.5.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.5.1` -> `2.5.2` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.room:room-paging/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.room:room-paging/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.room:room-paging/2.5.1/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.room:room-paging/2.5.1/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[androidx.room:room-compiler](https://developer.android.com/jetpack/androidx/releases/room#2.5.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.5.1` -> `2.5.2` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.room:room-compiler/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.room:room-compiler/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.room:room-compiler/2.5.1/2.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.room:room-compiler/2.5.1/2.5.2?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:eyJjcmVhdGVkSW5WZXIiOiIzNS4xMzEuMCIsInVwZGF0ZWRJblZlciI6IjM2LjguMTEiLCJ0YXJnZXRCcmFuY2giOiJkZXZlbG9wIn0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-29 15:55:54 +02:00
Yannis D b5bed897c6 Translated using Weblate (Greek)
Currently translated at 17.2% (105 of 610 strings)

Co-authored-by: Yannis D <i.doganos@tutamail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/el/
Translation: Tusky/Tusky
2023-07-29 12:53:03 +02:00
XoseM b58219f8cf Translated using Weblate (Galician)
Currently translated at 100.0% (610 of 610 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-07-29 12:53:03 +02:00
Hồ Nhất Duy fb94585a85 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (610 of 610 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-29 12:53:03 +02:00
Ihor Hordiichuk c4a7532ee5 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (610 of 610 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-07-29 12:53:03 +02:00
Eric 18dfc39bd1 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (610 of 610 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-07-29 12:53:03 +02:00
Danial Behzadi 31af6c3e0c Translated using Weblate (Persian)
Currently translated at 100.0% (610 of 610 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-29 12:53:03 +02:00
Yannis D 6601ecc020 Translated using Weblate (Greek)
Currently translated at 12.9% (4 of 31 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/el/
2023-07-28 16:02:40 +02:00
Ihor Hordiichuk 28f31efa21 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (31 of 31 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/uk/
2023-07-28 16:02:40 +02:00
Konrad Pozniak 97713b06da
Always update the filter display, even when the list is empty (#3880)
Fixes #3839, https://github.com/tuskyapp/Tusky/issues/3525
2023-07-27 13:44:28 +02:00
Ihor Hordiichuk 500271d4b3 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-07-27 13:07:46 +02:00
Konrad Pozniak ee711598c7
Cache locked state of connected accounts (#3790)
Small improvement to the behavior on bad/disconnected networks

Fixes https://github.com/tuskyapp/Tusky/issues/3773
2023-07-27 00:09:26 +02:00
Goooler 1bdf19407c
Use org.gradle.configuration-cache flag (#3792)
Follow up #3528.

https://docs.gradle.org/8.1/userguide/upgrading_version_8.html#configuration_caching_options_renamed
2023-07-27 00:06:26 +02:00
Nik Clayton e852aa64a5
Show the throwable error message when an upload fails (#3838) 2023-07-26 23:36:29 +02:00
renovate[bot] 8d612375e5
Update dependency androidx.paging:paging-runtime-ktx to v3.2.0 (#3887)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
|
[androidx.paging:paging-runtime-ktx](https://developer.android.com/jetpack/androidx/releases/paging#3.2.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.1.1` -> `3.2.0` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-runtime-ktx/3.2.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.paging:paging-runtime-ktx/3.2.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.paging:paging-runtime-ktx/3.1.1/3.2.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-runtime-ktx/3.1.1/3.2.0?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 this update
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:eyJjcmVhdGVkSW5WZXIiOiIzNi4xMS4wIiwidXBkYXRlZEluVmVyIjoiMzYuMTEuMCIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-26 23:30:41 +02:00
renovate[bot] 0694764b33
Update dependency androidx.fragment:fragment-ktx to v1.6.1 (#3884)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
|
[androidx.fragment:fragment-ktx](https://developer.android.com/jetpack/androidx/releases/fragment#1.6.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.6.0` -> `1.6.1` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.fragment:fragment-ktx/1.6.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/androidx.fragment:fragment-ktx/1.6.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/androidx.fragment:fragment-ktx/1.6.0/1.6.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.fragment:fragment-ktx/1.6.0/1.6.1?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 this update
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:eyJjcmVhdGVkSW5WZXIiOiIzNi4xMS4wIiwidXBkYXRlZEluVmVyIjoiMzYuMTEuMCIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-26 22:55:31 +02:00
Nik Clayton e35cc2dcef
Enable gradle build cache for Bitrise builds (#3840)
Read/write to the cache for nightly / release builds, read from the
cache for other builds.
2023-07-26 22:54:30 +02:00
Nik Clayton 406152d5b9
Clear listeners, hide icons when appropriate (#3870)
Preferences are shown using view holders.

The previous code did not clear the listeners or hide the icons if
necessary.

The practical upshot of this was that if you had two or more slider
preferences, *and* they were situated more than a screen's height apart,
the viewholder from the first one would get reused.

And if the first one enabled icons then the second one would show them.
And clicking on the second one would also call the listeners for the
first one.
2023-07-26 17:00:08 +02:00
renovate[bot] 0b01efed5e
Update coroutines to v1.7.3 (#3879)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
|
[org.jetbrains.kotlinx:kotlinx-coroutines-test](https://togithub.com/Kotlin/kotlinx.coroutines)
| `1.7.2` -> `1.7.3` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlinx:kotlinx-coroutines-test/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/org.jetbrains.kotlinx:kotlinx-coroutines-test/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/org.jetbrains.kotlinx:kotlinx-coroutines-test/1.7.2/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlinx:kotlinx-coroutines-test/1.7.2/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[org.jetbrains.kotlinx:kotlinx-coroutines-rx3](https://togithub.com/Kotlin/kotlinx.coroutines)
| `1.7.2` -> `1.7.3` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlinx:kotlinx-coroutines-rx3/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/org.jetbrains.kotlinx:kotlinx-coroutines-rx3/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/org.jetbrains.kotlinx:kotlinx-coroutines-rx3/1.7.2/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlinx:kotlinx-coroutines-rx3/1.7.2/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[org.jetbrains.kotlinx:kotlinx-coroutines-android](https://togithub.com/Kotlin/kotlinx.coroutines)
| `1.7.2` -> `1.7.3` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlinx:kotlinx-coroutines-android/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/org.jetbrains.kotlinx:kotlinx-coroutines-android/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/org.jetbrains.kotlinx:kotlinx-coroutines-android/1.7.2/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlinx:kotlinx-coroutines-android/1.7.2/1.7.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>Kotlin/kotlinx.coroutines
(org.jetbrains.kotlinx:kotlinx-coroutines-test)</summary>

###
[`v1.7.3`](https://togithub.com/Kotlin/kotlinx.coroutines/blob/HEAD/CHANGES.md#Version-173)

[Compare
Source](https://togithub.com/Kotlin/kotlinx.coroutines/compare/1.7.2...1.7.3)

- Disabled the publication of the multiplatform library metadata for the
old (1.6 and earlier) KMP Gradle plugin
([#&#8203;3809](https://togithub.com/Kotlin/kotlinx.coroutines/issues/3809)).
- Fixed a bug introduced in 1.7.2 that disabled the coroutine debugger
in IDEA
([#&#8203;3822](https://togithub.com/Kotlin/kotlinx.coroutines/issues/3822)).

</details>

---

### 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:eyJjcmVhdGVkSW5WZXIiOiIzNi4xMS4wIiwidXBkYXRlZEluVmVyIjoiMzYuMTEuMCIsInRhcmdldEJyYW5jaCI6ImRldmVsb3AifQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-26 15:52:28 +02:00
Sveinn í Felli 9824694b86 Translated using Weblate (Icelandic)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/
Translation: Tusky/Tusky
2023-07-24 21:48:21 +02:00
Conny Duck f919252ede remove import wrongly added by auto refactoring 2023-07-05 20:43:24 +02:00
Conny Duck 1a849a7385 update lint-baseline.xml 2023-07-05 20:21:32 +02:00
Conny Duck c84815525f ui improvements 2023-07-05 20:11:12 +02:00
Conny Duck f8a6e0d8b8 introduce DomainBlocksRepository 2023-07-05 20:09:16 +02:00
Conny Duck 8e56b5429b introduce SnackbarEvent instead of DomainBlockEvent 2023-07-05 19:42:30 +02:00
Conny Duck 2393ff1e4d rename item_muted_domain to item_blocked_domain 2023-07-05 19:23:04 +02:00
Conny Duck 4670964362 rename module and classes to domainblocks 2023-07-04 19:41:36 +02:00
Conny Duck 626a8760ae refactor instance blocks to paging 2023-07-04 19:30:57 +02:00
Conny Duck b18193e6c3 add logging to NetworkTimelineRemoteMediator 2023-05-08 19:27:00 +02:00
Conny Duck 3ac78c82c9 don't crash on unexpected json responses 2023-05-08 19:24:57 +02:00
523 changed files with 11213 additions and 15176 deletions

View File

@ -7,11 +7,13 @@ indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{java,kt}]
# Disable wildcard imports
[*.{java, kt}]
ij_kotlin_name_count_to_use_star_import = 999
ij_kotlin_name_count_to_use_star_import_for_members = 999
ij_java_class_count_to_use_import_on_demand = 999
# Enable trailing comma
ktlint_disabled_rules=trailing-comma-on-call-site,trailing-comma-on-declaration-site
[*.{yml,yaml}]
indent_size = 2

24
.github/ci-gradle.properties vendored Normal file
View File

@ -0,0 +1,24 @@
#
# 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>.
#
# CI build workers are ephemeral, so don't benefit from the Gradle daemon
org.gradle.daemon=false
org.gradle.parallel=true
org.gradle.workers.max=2
kotlin.incremental=false
kotlin.compiler.execution.strategy=in-process

View File

@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-java@v1
with:
java-version: 11
java-version: 17
- run: |
chmod +x ./gradlew
@ -35,7 +35,7 @@ jobs:
- uses: actions/setup-java@v1
with:
java-version: 11
java-version: 17
- run: |
chmod +x ./gradlew

View File

@ -17,25 +17,28 @@ jobs:
- uses: actions/setup-java@v3
with:
java-version: '11'
java-version: '17'
distribution: 'temurin'
- name: Gradle Wrapper Validation
uses: gradle/wrapper-validation-action@v1
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- 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

@ -0,0 +1,34 @@
# Build the app on each push to `develop`, populating the build cache to speed
# up CI on PRs.
name: Populate build cache
on:
push:
branches:
- develop
jobs:
build:
name: app:buildGreenDebug
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Gradle Wrapper Validation
uses: gradle/wrapper-validation-action@v1
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- uses: gradle/gradle-build-action@v2
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
- name: Run app:buildGreenDebug
run: ./gradlew app:buildGreenDebug

View File

@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-java@v1
with:
java-version: 11
java-version: 17
- run: |
chmod +x ./gradlew

View File

@ -22,8 +22,9 @@ We try to follow the [Guide to app architecture](https://developer.android.com/t
### Kotlin
Tusky was originally written in Java, but is in the process of migrating to Kotlin. All new code must be written in Kotlin.
We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint).
You can check the codestyle by running `./gradlew ktlintCheck`.
We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint).
You can check the codestyle by running `./gradlew ktlintCheck lint`. This will fail if you have any errors, and produces a detailed report which also lists warnings.
We intentionally have very few hard linting errors, so that new contributors can focus on what they want to achieve instead of fighting the linter.
### Text
All English text that will be visible to users must be put in `app/src/main/res/values/strings.xml` so it is translateable into other languages.
@ -42,12 +43,15 @@ All icons are from the Material iconset, find new icons [here](https://fonts.goo
We try to make Tusky as accessible as possible for as many people as possible. Please make sure that all touch targets are at least 48dpx48dp in size, Text has sufficient contrast and images or icons have a image description. See [this guide](https://developer.android.com/guide/topics/ui/accessibility/apps) for more information.
### Supported servers
Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon Api, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky but no special effort is made to support their quirks or additional features.
Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon API, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky, but no special effort is made to support their quirks or additional features.
### Payment Policy
Our payment policy may be viewed [here](https://github.com/tuskyapp/Tusky/blob/develop/doc/PaymentPolicy.md).
## Troubleshooting / FAQ
- Tusky should be built with the newest version of Android Studio
- Tusky should be built with the newest version of Android Studio.
- Tusky comes with two sets of build variants, "blue" and "green", which can be installed simultaneously and are distinguished by the colors of their icons. Green is intended for local development and testing, whereas blue is for releases.
## Resources
- [Mastodon Api documentation](https://docs.joinmastodon.org/api/)
- [Mastodon API documentation](https://docs.joinmastodon.org/api/)

View File

@ -27,10 +27,10 @@ android {
defaultConfig {
applicationId APP_ID
namespace "com.keylesspalace.tusky"
minSdk 23
minSdk 24
targetSdk 33
versionCode 56
versionName '4.6.0'
versionCode 57
versionName '4.7.0'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@ -68,8 +68,7 @@ android {
lint {
lintConfig file("lint.xml")
// Regenerate by deleting app/lint-baseline.xml, then run:
// ./gradlew lintBlueDebug
// Regenerate by running `./gradlew app:newLintBaseline`
baseline = file("lint-baseline.xml")
}
@ -108,12 +107,6 @@ android {
includeInApk false
includeInBundle false
}
// Can remove this once https://issuetracker.google.com/issues/260059413 is fixed.
// https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
applicationVariants.configureEach { variant ->
variant.outputs.configureEach {
outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" +
@ -166,7 +159,7 @@ dependencies {
implementation libs.sparkbutton
implementation libs.photoview
implementation libs.touchimageview
implementation libs.bundles.material.drawer
implementation libs.material.typeface
@ -197,3 +190,20 @@ dependencies {
implementation libs.accelfeaster
implementation libs.jsoup
}
// Work around warnings of:
// WARNING: Illegal reflective access by org.jetbrains.kotlin.kapt3.util.ModuleManipulationUtilsKt (file:/C:/Users/Andi/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-annotation-processing-gradle/1.8.22/28dab7e0ee9ce62c03bf97de3543c911dc653700/kotlin-annotation-processing-gradle-1.8.22.jar) to constructor com.sun.tools.javac.util.Context()
// See https://youtrack.jetbrains.com/issue/KT-30589/Kapt-An-illegal-reflective-access-operation-has-occurred
tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask).configureEach {
kaptProcessJvmArgs.addAll([
"--add-opens", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"])
}

File diff suppressed because one or more lines are too long

View File

@ -29,20 +29,47 @@
Disable these for the time being. -->
<issue id="UnusedIds" severity="ignore" />
<issue id="UnusedResources" severity="ignore" />
<!-- Logs are stripped in release builds. -->
<issue id="LogConditional" severity="ignore" />
<!-- Ensure we are warned about errors in the baseline -->
<issue id="LintBaseline" severity="warning" />
<!-- Newer dependencies are handled by Renovate, and don't need a warning -->
<issue id="GradleDependency" severity="ignore" />
<!-- 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" />
<!-- Typographical punctuation is not something we care about at the moment -->
<issue id="TypographyQuotes" severity="ignore" />
<issue id="TypographyDashes" severity="ignore" />
<issue id="TypographyEllipsis" severity="ignore" />
<!-- Set OldTargetApi back to warning -->
<issue id="OldTargetApi" severity="warning" />
<!-- Translations come from external parties -->
<issue id="MissingQuantity" severity="ignore" />
<issue id="ImpliedQuantity" severity="ignore" />
<!-- Most alleged typos are in translations -->
<issue id="Typos" severity="ignore" />
<!-- Mark all other lint issues as errors -->
<issue id="all" severity="error" />
<!-- Basically all of our vectors are external -->
<issue id="VectorPath" severity="ignore" />
<issue id="Overdraw" severity="ignore" />
<!-- Irrelevant api version warnings -->
<issue id="OldTargetApi" severity="ignore" />
<issue id="UnusedAttribute" severity="ignore" />
<!-- We do not *want* all the text in the app to be selectable -->
<issue id="SelectableText" severity="ignore" />
<!-- This is heavily used by the viewbinding helper -->
<issue id="SyntheticAccessor" severity="ignore" />
<!-- Things we would actually question in a code review -->
<issue id="MissingPermission" severity="error" />
<issue id="InvalidPackage" severity="error" />
<issue id="UseCompatLoadingForDrawables" severity="error" />
<issue id="UseCompatTextViewDrawableXml" severity="error" />
<issue id="Recycle" severity="error" />
<issue id="KeyboardInaccessibleWidget" severity="error" />
<!-- Mark all other lint issues as warnings -->
<issue id="all" severity="warning" />
</lint>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -131,7 +131,7 @@
android:theme="@style/Base.Theme.AppCompat" />
<activity
android:name=".components.search.SearchActivity"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
@ -149,7 +149,7 @@
<activity
android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".components.instancemute.InstanceListActivity" />
<activity android:name=".components.domainblocks.DomainBlocksActivity" />
<activity android:name=".components.scheduled.ScheduledStatusActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" />
@ -189,8 +189,7 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/tusky_compose_post_quicksetting_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true"
tools:targetApi="24">
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>

View File

@ -1,6 +1,10 @@
package com.keylesspalace.tusky
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.text.SpannableString
import android.text.SpannableStringBuilder
@ -8,14 +12,22 @@ import android.text.method.LinkMovementMethod
import android.text.style.URLSpan
import android.text.util.Linkify
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityAboutBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.NoUnderlineURLSpan
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import kotlinx.coroutines.launch
import javax.inject.Inject
import net.accelf.yuito.AccessTokenLoginActivity
class AboutActivity : BottomSheetActivity(), Injectable {
@Inject
lateinit var instanceInfoRepository: InstanceInfoRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -37,6 +49,28 @@ class AboutActivity : BottomSheetActivity(), Injectable {
binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME)
binding.deviceInfo.text = getString(
R.string.about_device_info,
Build.MANUFACTURER,
Build.MODEL,
Build.VERSION.RELEASE,
Build.VERSION.SDK_INT
)
lifecycleScope.launch {
accountManager.activeAccount?.let { account ->
val instanceInfo = instanceInfoRepository.getInstanceInfo()
binding.accountInfo.text = getString(
R.string.about_account_info,
account.username,
account.domain,
instanceInfo.version
)
binding.accountInfoTitle.show()
binding.accountInfo.show()
}
}
if (BuildConfig.CUSTOM_INSTANCE.isBlank()) {
binding.aboutPoweredByTusky.hide()
}
@ -53,6 +87,16 @@ class AboutActivity : BottomSheetActivity(), Injectable {
binding.aboutLicensesButton.setOnClickListener {
startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java))
}
binding.copyDeviceInfo.setOnClickListener {
val text = "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}"
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Tusky version information", text)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(this, getString(R.string.about_copied), Toast.LENGTH_SHORT).show()
}
}
}
private fun onEasterEggExecute() {

View File

@ -47,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.AppTheme;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.ThemeUtils;
@ -56,10 +57,13 @@ import java.util.List;
import javax.inject.Inject;
import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME;
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
private static final String TAG = "BaseActivity";
@Inject
@NonNull
public AccountManager accountManager;
private static final int REQUESTER_NONE = Integer.MAX_VALUE;
@ -74,9 +78,9 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
/* There isn't presently a way to globally change the theme of a whole application at
* runtime, just individual activities. So, each activity has to set its theme before any
* views are created. */
String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT);
String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue());
Log.d("activeTheme", theme);
if (theme.equals("black")) {
if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) {
setTheme(R.style.TuskyBlackTheme);
}
@ -87,7 +91,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
int style = textStyle(preferences.getString("statusTextSize", "medium"));
int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"));
getTheme().applyStyle(style, true);
if(requiresLogin()) {
@ -162,13 +166,13 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
return style;
}
public void startActivityWithSlideInAnimation(Intent intent) {
public void startActivityWithSlideInAnimation(@NonNull Intent intent) {
super.startActivity(intent);
overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == android.R.id.home) {
getOnBackPressedDispatcher().onBackPressed();
return true;
@ -196,7 +200,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
}
}
protected void showErrorDialog(View anyView, @StringRes int descriptionId, @StringRes int actionId, View.OnClickListener listener) {
protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) {
if (anyView != null) {
Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT);
bar.setAction(actionId, listener);
@ -204,7 +208,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
}
}
public void showAccountChooserDialog(CharSequence dialogTitle, boolean showActiveAccount, AccountSelectionListener listener) {
public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) {
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
AccountEntity activeAccount = accountManager.getActiveAccount();
@ -256,9 +260,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
public void openAsAccount(@NonNull String url, @NonNull AccountEntity 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);
Intent intent = MainActivity.redirectIntent(this, account.getId(), url);
startActivity(intent);
finishWithoutSlideOutAnimation();
}
@ -272,7 +275,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
}
}
public void requestPermissions(String[] permissions, PermissionRequester requester) {
public void requestPermissions(@NonNull String[] permissions, @NonNull PermissionRequester requester) {
ArrayList<String> permissionsToRequest = new ArrayList<>();
for(String permission: permissions) {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {

View File

@ -26,6 +26,7 @@ import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.network.MastodonApi
@ -94,7 +95,7 @@ abstract class BottomSheetActivity : BaseActivity() {
viewThread(statuses[0].id, statuses[0].url)
return@subscribe
}
accounts.firstOrNull { it.url == url }?.let { account ->
accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account ->
// Some servers return (unrelated) accounts for url searches (#2804)
// Verify that the account's url matches the query
viewAccount(account.id)

View File

@ -25,7 +25,9 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
@ -46,9 +48,11 @@ import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.await
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.keylesspalace.tusky.viewmodel.ProfileDataInUi
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@ -96,6 +100,14 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
private val currentProfileData
get() = ProfileDataInUi(
displayName = binding.displayNameEditText.text.toString(),
note = binding.noteEditText.text.toString(),
locked = binding.lockedCheckBox.isChecked,
fields = accountFieldEditAdapter.getFieldData()
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -200,17 +212,26 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
}
val onBackCallback = object : OnBackPressedCallback(enabled = true) {
override fun handleOnBackPressed() = checkForUnsavedChanges()
}
onBackPressedDispatcher.addCallback(this, onBackCallback)
}
fun checkForUnsavedChanges() {
if (viewModel.hasUnsavedChanges(currentProfileData)) {
showUnsavedChangesDialog()
} else {
finish()
}
}
override fun onStop() {
super.onStop()
if (!isFinishing) {
viewModel.updateProfile(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData()
)
viewModel.updateProfile(currentProfileData)
}
}
@ -287,14 +308,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
return super.onOptionsItemSelected(item)
}
private fun save() {
viewModel.save(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData()
)
}
private fun save() = viewModel.save(currentProfileData)
private fun onSaveFailure(msg: String?) {
val errorMsg = msg ?: getString(R.string.error_media_upload_sending)
@ -306,4 +320,16 @@ class EditProfileActivity : BaseActivity(), Injectable {
Log.w("EditProfileActivity", "failed to pick media", throwable)
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
}
private fun showUnsavedChangesDialog() = lifecycleScope.launch {
when (launchSaveDialog()) {
AlertDialog.BUTTON_POSITIVE -> save()
else -> finish()
}
}
private suspend fun launchSaveDialog() = AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_save_profile_changes_message))
.create()
.await(R.string.action_save, R.string.action_discard)
}

View File

@ -23,8 +23,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView
@ -38,10 +36,10 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.databinding.ActivityListsBinding
import com.keylesspalace.tusky.databinding.DialogListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList
@ -118,7 +116,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
viewModel.events.collect { event ->
when (event) {
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list)
Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
}
}
@ -126,16 +124,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
private fun showlistNameDialog(list: MastoList?) {
val layout = FrameLayout(this)
val editText = EditText(this)
editText.setHint(R.string.hint_list_name)
layout.addView(editText)
val margin = Utils.dpToPx(this, 8)
(editText.layoutParams as ViewGroup.MarginLayoutParams)
.setMargins(margin, margin, margin, 0)
val binding = DialogListBinding.inflate(layoutInflater).apply {
replyPolicySpinner.setSelection(MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal)
}
val dialog = AlertDialog.Builder(this)
.setView(layout)
.setView(binding.root)
.setPositiveButton(
if (list == null) {
R.string.action_create_list
@ -143,17 +136,31 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
R.string.action_rename_list
}
) { _, _ ->
onPickedDialogName(editText.text, list?.id)
onPickedDialogName(
binding.nameText.text.toString(),
list?.id,
binding.exclusiveCheckbox.isChecked,
MastoList.ReplyPolicy.entries[binding.replyPolicySpinner.selectedItemPosition].policy
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
editText.doOnTextChanged { s, _, _, _ ->
positiveButton.isEnabled = s?.isNotBlank() == true
binding.nameText.let { editText ->
editText.doOnTextChanged { s, _, _, _ ->
dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true
}
editText.setText(list?.title)
editText.text?.let { editText.setSelection(it.length) }
}
list?.let {
if (it.exclusive == null) {
binding.exclusiveCheckbox.visible(false)
} else {
binding.exclusiveCheckbox.isChecked = it.exclusive
}
}
editText.setText(list?.title)
editText.text?.let { editText.setSelection(it.length) }
}
private fun showListDeleteDialog(list: MastoList) {
@ -174,13 +181,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
INITIAL, LOADING -> binding.messageView.hide()
ERROR_NETWORK -> {
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) {
viewModel.retryLoading()
}
}
ERROR_OTHER -> {
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
viewModel.retryLoading()
}
}
@ -192,6 +199,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
R.string.message_empty,
null
)
binding.messageView.showHelp(R.string.help_empty_lists)
} else {
binding.messageView.hide()
}
@ -226,7 +234,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.list_edit -> openListSettings(list)
R.id.list_rename -> renameListDialog(list)
R.id.list_update -> renameListDialog(list)
R.id.list_delete -> showListDeleteDialog(list)
else -> return@setOnMenuItemClickListener false
}
@ -287,11 +295,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
}
private fun onPickedDialogName(name: CharSequence, listId: String?) {
private fun onPickedDialogName(name: String, listId: String?, exclusive: Boolean, replyPolicy: String) {
if (listId == null) {
viewModel.createNewList(name.toString())
viewModel.createNewList(name, exclusive, replyPolicy)
} else {
viewModel.renameList(listId, name.toString())
viewModel.updateList(listId, name, exclusive, replyPolicy)
}
}

View File

@ -17,6 +17,7 @@ package com.keylesspalace.tusky
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
@ -30,6 +31,7 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.InsetDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
@ -38,6 +40,7 @@ import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.MenuItem.SHOW_AS_ACTION_NEVER
import android.view.View
import android.view.WindowManager
import android.widget.ImageView
@ -50,9 +53,12 @@ import androidx.appcompat.widget.PopupMenu
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.GravityCompat
import androidx.core.view.MenuProvider
import androidx.core.view.forEach
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer
@ -69,8 +75,11 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.CacheUpdater
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
@ -81,7 +90,6 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canH
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
@ -96,6 +104,8 @@ import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.FabFragment
@ -115,7 +125,6 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@ -186,7 +195,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private lateinit var header: AccountHeaderView
private var notificationTabPosition = 0
private var onTabSelectedListener: OnTabSelectedListener? = null
private var unreadAnnouncementsCount = 0
@ -195,8 +203,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private lateinit var glide: RequestManager
private var accountLocked: Boolean = false
// We need to know if the emoji pack has been changed
private var selectedEmojiPack: String? = null
@ -206,6 +212,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
/** Adapter for the different timeline tabs */
private lateinit var tabAdapter: MainPagerAdapter
private var directMessageTab: TabLayout.Tab? = null
@Suppress("DEPRECATION")
@SuppressLint("RestrictedApi")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -213,30 +223,39 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
?: return // will be redirected to LoginActivity by BaseActivity
var showNotificationTab = false
if (intent != null) {
// check for savedInstanceState in order to not handle intent events more than once
if (intent != null && savedInstanceState == null) {
val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1)
if (notificationId != -1) {
// opened from a notification action, cancel the notification
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId)
}
/** there are two possibilities the accountId can be passed to MainActivity:
* - from our code as long 'account_id'
* - from our code as Long Intent Extra TUSKY_ACCOUNT_ID
* - from share shortcuts as String 'android.intent.extra.shortcut.ID'
*/
var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1)
if (accountId == -1L) {
var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1)
if (tuskyAccountId == -1L) {
val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)
if (accountIdString != null) {
accountId = accountIdString.toLong()
tuskyAccountId = accountIdString.toLong()
}
}
val accountRequested = accountId != -1L
if (accountRequested && accountId != activeAccount.id) {
accountManager.setActiveAccount(accountId)
val accountRequested = tuskyAccountId != -1L
if (accountRequested && tuskyAccountId != activeAccount.id) {
accountManager.setActiveAccount(tuskyAccountId)
}
val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false)
if (canHandleMimeType(intent.type)) {
if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) {
// Sharing to Tusky from an external app
if (accountRequested) {
// The correct account is already active
forwardShare(intent)
forwardToComposeActivity(intent)
} else {
// No account was provided, show the chooser
showAccountChooserDialog(
@ -247,10 +266,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val requestedId = account.id
if (requestedId == activeAccount.id) {
// The correct account is already active
forwardShare(intent)
forwardToComposeActivity(intent)
} else {
// A different account was requested, restart the activity
intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId)
intent.putExtra(TUSKY_ACCOUNT_ID, requestedId)
changeAccount(requestedId, intent)
}
}
@ -260,11 +279,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} else if (openDrafts) {
val intent = DraftsActivity.newIntent(this)
startActivity(intent)
} else if (accountRequested && savedInstanceState == null) {
} else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) {
// user clicked a notification, show follow requests for type FOLLOW_REQUEST,
// otherwise show notification tab
if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true)
if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS)
startActivityWithSlideInAnimation(intent)
} else {
showNotificationTab = true
@ -273,15 +292,27 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
setContentView(binding.root)
setSupportActionBar(binding.mainToolbar)
glide = Glide.with(this)
binding.viewQuickToot.attachViewModel(quickTootViewModel, this)
binding.composeButton.setOnClickListener(binding.viewQuickToot::onFABClicked)
// Determine which of the three toolbars should be the supportActionBar (which hosts
// the options menu).
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
binding.mainToolbar.visible(!hideTopToolbar)
if (hideTopToolbar) {
when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) {
"top" -> setSupportActionBar(binding.topNav)
"bottom" -> setSupportActionBar(binding.bottomNav)
}
binding.mainToolbar.hide()
// There's not enough space in the top/bottom bars to show the title as well.
supportActionBar?.setDisplayShowTitleEnabled(false)
} else {
setSupportActionBar(binding.mainToolbar)
binding.mainToolbar.show()
}
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
@ -292,7 +323,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
setupDrawer(
savedInstanceState,
addSearchButton = hideTopToolbar,
addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING)
addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS),
addTrendingStatusesButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_STATUSES),
)
/* Fetch user info while we're doing other things. This has to be done after setting up the
@ -321,16 +353,38 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
is MainTabsChangedEvent -> {
refreshMainDrawerItems(
addSearchButton = hideTopToolbar,
addTrendingButton = !event.newTabs.hasTab(TRENDING)
addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS),
addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES),
)
setupTabs(false)
}
is AnnouncementReadEvent -> {
unreadAnnouncementsCount--
updateAnnouncementsBadge()
}
is NewNotificationsEvent -> {
directMessageTab?.let { tab ->
if (event.accountId == activeAccount.accountId) {
val hasDirectMessageNotification =
event.notifications.any { it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT }
if (hasDirectMessageNotification) {
showDirectMessageBadge(true)
}
}
}
}
is NotificationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
showDirectMessageBadge(false)
}
}
is ConversationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
showDirectMessageBadge(false)
}
}
}
binding.viewQuickToot.handleEvent(event)
}
@ -362,7 +416,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
if (
Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
@ -374,6 +431,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
draftsAlert.observeInContext(this, true)
}
private fun showDirectMessageBadge(showBadge: Boolean) {
directMessageTab?.let { tab ->
tab.badge?.isVisible = showBadge
// TODO a bit cumbersome (also for resetting)
accountManager.activeAccount?.let {
if (it.hasDirectMessageBadge != showBadge) {
it.hasDirectMessageBadge = showBadge
accountManager.saveAccount(it)
}
}
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_main, menu)
menu.findItem(R.id.action_search)?.apply {
@ -384,6 +455,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
// If the main toolbar is hidden then there's no space in the top/bottomNav to show
// the menu items as icons, so forceably disable them
if (!binding.mainToolbar.isVisible) menu.forEach { it.setShowAsAction(SHOW_AS_ACTION_NEVER) }
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_search -> {
@ -456,12 +535,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
}
private fun forwardShare(intent: Intent) {
val composeIntent = Intent(this, ComposeActivity::class.java)
composeIntent.action = intent.action
composeIntent.type = intent.type
composeIntent.putExtras(intent)
composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
private fun forwardToComposeActivity(intent: Intent) {
val composeOptions = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS, ComposeActivity.ComposeOptions::class.java)
val composeIntent = if (composeOptions != null) {
ComposeActivity.startIntent(this, composeOptions)
} else {
Intent(this, ComposeActivity::class.java).apply {
action = intent.action
type = intent.type
putExtras(intent)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
startActivity(composeIntent)
finish()
}
@ -469,13 +555,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun setupDrawer(
savedInstanceState: Bundle?,
addSearchButton: Boolean,
addTrendingButton: Boolean
addTrendingTagsButton: Boolean,
addTrendingStatusesButton: Boolean,
) {
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
binding.topNavAvatar.setOnClickListener(drawerOpenClickListener)
binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener)
binding.topNav.setNavigationOnClickListener(drawerOpenClickListener)
binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener)
header = AccountHeaderView(this).apply {
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
@ -500,7 +587,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
@ -522,7 +609,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
override fun placeholder(ctx: Context, tag: String?): Drawable {
if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) {
return ctx.getDrawable(R.drawable.avatar_default)!!
return AppCompatResources.getDrawable(ctx, R.drawable.avatar_default)!!
}
return super.placeholder(ctx, tag)
@ -530,12 +617,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
})
binding.mainDrawer.apply {
refreshMainDrawerItems(addSearchButton, addTrendingButton)
refreshMainDrawerItems(
addSearchButton = addSearchButton,
addTrendingTagsButton = addTrendingTagsButton,
addTrendingStatusesButton = addTrendingStatusesButton,
)
setSavedInstance(savedInstanceState)
}
}
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
private fun refreshMainDrawerItems(
addSearchButton: Boolean,
addTrendingTagsButton: Boolean,
addTrendingStatusesButton: Boolean,
) {
binding.mainDrawer.apply {
itemAdapter.clear()
tintStatusBar = true
@ -569,7 +664,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
nameRes = R.string.action_view_follow_requests
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
onClick = {
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS)
startActivityWithSlideInAnimation(intent)
}
},
@ -657,7 +752,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
)
}
if (addTrendingButton) {
if (addTrendingTagsButton) {
binding.mainDrawer.addItemsAtPosition(
5,
primaryDrawerItem {
@ -669,6 +764,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
)
}
if (addTrendingStatusesButton) {
binding.mainDrawer.addItemsAtPosition(
6,
primaryDrawerItem {
nameRes = R.string.title_public_trending_statuses
iconicsIcon = GoogleMaterial.Icon.gmd_local_fire_department
onClick = {
startActivityWithSlideInAnimation(StatusListActivity.newTrendingIntent(context))
}
}
)
}
}
if (BuildConfig.DEBUG) {
@ -747,6 +855,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
// Detach any existing mediator before changing tab contents and attaching a new mediator
tabLayoutMediator?.detach()
directMessageTab = null
tabAdapter.tabs = tabs
tabAdapter.notifyItemRangeChanged(0, tabs.size)
@ -763,6 +873,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
if (popups[position] != null) {
return@TabLayoutMediator
}
if (tabs[position].id == DIRECT) {
tab.orCreateBadge
tab.badge?.isVisible = accountManager.activeAccount?.hasDirectMessageBadge ?: false
directMessageTab = tab
}
val popup = PopupMenu(this, tab.view)
popup.menuInflater.inflate(R.menu.view_tab_action, popup.menu)
@ -859,6 +974,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
binding.mainToolbar.title = tab.contentDescription
refreshComposeButtonState(tabAdapter, tab.position)
if (tab == directMessageTab) {
tab.badge?.isVisible = false
accountManager.activeAccount?.let {
if (it.hasDirectMessageBadge) {
it.hasDirectMessageBadge = false
accountManager.saveAccount(it)
}
}
}
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
@ -870,8 +996,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
activeTabLayout.addOnTabSelectedListener(it)
}
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity)
supportActionBar?.title = tabs[position].title(this@MainActivity)
binding.mainToolbar.setOnClickListener {
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
}
@ -998,122 +1123,84 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
disableAllNotifications(this, accountManager)
}
accountLocked = me.locked
updateProfiles()
updateShortcut(this, accountManager.activeAccount!!)
}
@SuppressLint("CheckResult")
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
if (hideTopToolbar) {
val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom"
val avatarView = if (navOnBottom) {
binding.bottomNavAvatar.show()
binding.bottomNavAvatar
val activeToolbar = if (hideTopToolbar) {
val navOnBottom = preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom"
if (navOnBottom) {
binding.bottomNav
} else {
binding.topNavAvatar.show()
binding.topNavAvatar
}
if (animateAvatars) {
Glide.with(this)
.load(avatarUrl)
.placeholder(R.drawable.avatar_default)
.into(avatarView)
} else {
Glide.with(this)
.asBitmap()
.load(avatarUrl)
.placeholder(R.drawable.avatar_default)
.into(avatarView)
binding.topNav
}
} else {
binding.bottomNavAvatar.hide()
binding.topNavAvatar.hide()
binding.mainToolbar
}
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
if (animateAvatars) {
glide.asDrawable()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
if (animateAvatars) {
glide.asDrawable().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)))
.apply {
if (showPlaceholder) placeholder(R.drawable.avatar_default)
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
if (resource is Animatable) resource.start()
activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
if (resource is Animatable) {
resource.start()
}
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
})
} else {
glide.asBitmap()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
override fun onLoadCleared(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
})
} else {
glide.asBitmap().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)))
.apply {
if (showPlaceholder) placeholder(R.drawable.avatar_default)
}
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(
BitmapDrawable(resources, resource),
navIconSize,
navIconSize
)
}
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) {
activeToolbar.navigationIcon = FixedSizeDrawable(
BitmapDrawable(resources, resource),
navIconSize,
navIconSize
)
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
})
}
}
})
}
}
@ -1175,8 +1262,75 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private const val TAG = "MainActivity" // logging tag
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
const val REDIRECT_URL = "redirectUrl"
const val OPEN_DRAFTS = "draft"
private const val REDIRECT_URL = "redirectUrl"
private const val OPEN_DRAFTS = "draft"
private const val TUSKY_ACCOUNT_ID = "tuskyAccountId"
private const val COMPOSE_OPTIONS = "composeOptions"
private const val NOTIFICATION_TYPE = "notificationType"
private const val NOTIFICATION_TAG = "notificationTag"
private const val NOTIFICATION_ID = "notificationId"
/**
* Switches the active account to the provided accountId and then stays on MainActivity
*/
@JvmStatic
fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId)
}
}
/**
* Switches the active account to the accountId and takes the user to the correct place according to the notification they clicked
*/
@JvmStatic
fun openNotificationIntent(context: Context, tuskyAccountId: Long, type: Notification.Type): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
putExtra(NOTIFICATION_TYPE, type.name)
}
}
/**
* Switches the active account to the accountId and then opens ComposeActivity with the provided options
* @param tuskyAccountId the id of the Tusky account to open the screen with. Set to -1 for current account.
* @param notificationId optional id of the notification that should be cancelled when this intent is opened
* @param notificationTag optional tag of the notification that should be cancelled when this intent is opened
*/
@JvmStatic
fun composeIntent(
context: Context,
options: ComposeActivity.ComposeOptions,
tuskyAccountId: Long = -1,
notificationTag: String? = null,
notificationId: Int = -1
): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
action = Intent.ACTION_SEND // so it can be opened via shortcuts
putExtra(COMPOSE_OPTIONS, options)
putExtra(NOTIFICATION_TAG, notificationTag)
putExtra(NOTIFICATION_ID, notificationId)
}
}
/**
* switches the active account to the accountId and then tries to resolve and show the provided url
*/
@JvmStatic
fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
putExtra(REDIRECT_URL, url)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
/**
* switches the active account to the provided accountId and then opens drafts
*/
fun draftIntent(context: Context, tuskyAccountId: Long): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
putExtra(OPEN_DRAFTS, true)
}
}
}
}

View File

@ -28,18 +28,20 @@ import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.filters.EditFilterActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import kotlinx.coroutines.launch
import net.accelf.yuito.QuickTootViewModel
import retrofit2.HttpException
import javax.inject.Inject
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@ -81,6 +83,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
Kind.FAVOURITES -> getString(R.string.title_favourites)
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses)
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
}
@ -146,6 +149,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
{
followTagItem?.isVisible = false
unfollowTagItem?.isVisible = true
Snackbar.make(binding.root, getString(R.string.following_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show()
},
{
Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
@ -166,6 +171,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
{
followTagItem?.isVisible = true
unfollowTagItem?.isVisible = false
Snackbar.make(binding.root, getString(R.string.unfollowing_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show()
},
{
Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
@ -183,6 +190,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
*/
private fun updateMuteTagMenuItems() {
val tag = hashtag ?: return
val hashedTag = "#$tag"
muteTagItem?.isVisible = true
muteTagItem?.isEnabled = false
@ -192,18 +200,17 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
mastodonApi.getFilters().fold(
{ filters ->
mutedFilter = filters.firstOrNull { filter ->
filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
it.keyword == tag
}
// TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)?
filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag
}
updateTagMuteState(mutedFilter != null)
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
if (throwable.isHttpNotFound()) {
mastodonApi.getFiltersV1().fold(
{ filters ->
mutedFilterV1 = filters.firstOrNull { filter ->
tag == filter.phrase && filter.context.contains(FilterV1.HOME)
hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME)
}
updateTagMuteState(mutedFilterV1 != null)
},
@ -235,6 +242,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
val tag = hashtag ?: return true
lifecycleScope.launch {
var filterCreateSuccess = false
val hashedTag = "#$tag"
mastodonApi.createFilter(
title = "#$tag",
context = listOf(FilterV1.HOME),
@ -242,19 +252,22 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
expiresInSeconds = null
).fold(
{ filter ->
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) {
mutedFilter = filter
updateTagMuteState(true)
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = hashedTag, wholeWord = true).isSuccess) {
// must be requested again; otherwise does not contain the keyword (but server does)
mutedFilter = mastodonApi.getFilter(filter.id).getOrNull()
// TODO the preference key here ("home") is not meaningful; should probably be another event if any
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
filterCreateSuccess = true
} else {
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to mute #$tag")
}
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
if (throwable.isHttpNotFound()) {
mastodonApi.createFilterV1(
tag,
hashedTag,
listOf(FilterV1.HOME),
irreversible = false,
wholeWord = true,
@ -262,8 +275,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
).fold(
{ filter ->
mutedFilterV1 = filter
updateTagMuteState(true)
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
filterCreateSuccess = true
},
{ throwable ->
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
@ -276,6 +289,24 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
}
}
)
if (filterCreateSuccess) {
updateTagMuteState(true)
Snackbar.make(binding.root, getString(R.string.muting_hashtag_success_format, tag), Snackbar.LENGTH_LONG).apply {
setAction(R.string.action_view_filter) {
val intent = if (mutedFilter != null) {
Intent(this@StatusListActivity, EditFilterActivity::class.java).apply {
putExtra(EditFilterActivity.FILTER_TO_EDIT, mutedFilter)
}
} else {
Intent(this@StatusListActivity, FiltersActivity::class.java)
}
startActivityWithSlideInAnimation(intent)
}
show()
}
}
}
return true
@ -321,6 +352,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
mutedFilterV1 = null
mutedFilter = null
Snackbar.make(binding.root, getString(R.string.unmuting_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show()
},
{ throwable ->
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
@ -365,5 +398,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
putExtra(EXTRA_KIND, Kind.TAG.name)
putExtra(EXTRA_HASHTAG, hashtag)
}
fun newTrendingIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.PUBLIC_TRENDING_STATUSES.name)
}
}
}

View File

@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.components.trending.TrendingFragment
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
import net.accelf.yuito.streaming.StreamType
import net.accelf.yuito.streaming.Subscription
import java.util.Objects
@ -35,9 +35,11 @@ const val NOTIFICATIONS = "Notifications"
const val LOCAL = "Local"
const val FEDERATED = "Federated"
const val DIRECT = "Direct"
const val TRENDING = "Trending"
const val TRENDING_TAGS = "TrendingTags"
const val TRENDING_STATUSES = "TrendingStatuses"
const val HASHTAG = "Hashtag"
const val LIST = "List"
const val BOOKMARKS = "Bookmarks"
const val STREAMING = "STR"
@ -71,9 +73,7 @@ data class TabData(
other as TabData
if (id != other.id) return false
if (arguments != other.arguments) return false
return true
return arguments == other.arguments
}
override fun hashCode() = Objects.hash(id, arguments)
@ -117,11 +117,17 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
icon = R.drawable.ic_reblog_direct_24dp,
fragment = { ConversationsFragment.newInstance() }
)
TRENDING -> TabData(
id = TRENDING,
TRENDING_TAGS -> TabData(
id = TRENDING_TAGS,
text = R.string.title_public_trending_hashtags,
icon = R.drawable.ic_trending_up_24px,
fragment = { TrendingFragment.newInstance() }
fragment = { TrendingTagsFragment.newInstance() }
)
TRENDING_STATUSES -> TabData(
id = TRENDING_STATUSES,
text = R.string.title_public_trending_statuses,
icon = R.drawable.ic_hot_24dp,
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) }
)
HASHTAG -> TabData(
id = HASHTAG,
@ -140,6 +146,12 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
title = { arguments.getOrNull(1).orEmpty() },
enableStreaming = enableStreaming,
)
BOOKMARKS -> TabData(
id = BOOKMARKS,
text = R.string.title_bookmarks,
icon = R.drawable.ic_bookmark_active_24dp,
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) }
)
else -> throw IllegalArgumentException("unknown tab type")
}
}

View File

@ -164,18 +164,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
toggleFab(false)
}
binding.maxTabsInfo.text = resources.getQuantityString(R.plurals.max_tab_number_reached, MAX_TAB_COUNT, MAX_TAB_COUNT)
updateAvailableTabs()
onBackPressedDispatcher.addCallback(onFabDismissedCallback)
}
override fun onTabAdded(tab: TabData) {
if (currentTabs.size >= MAX_TAB_COUNT) {
return
}
toggleFab(false)
if (tab.id == HASHTAG) {
@ -386,17 +380,23 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
if (!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab)
}
val trendingTab = createTabDataFromId(TRENDING)
if (!currentTabs.contains(trendingTab)) {
addableTabs.add(trendingTab)
val trendingTagsTab = createTabDataFromId(TRENDING_TAGS)
if (!currentTabs.contains(trendingTagsTab)) {
addableTabs.add(trendingTagsTab)
}
val bookmarksTab = createTabDataFromId(BOOKMARKS)
if (!currentTabs.contains(trendingTagsTab)) {
addableTabs.add(bookmarksTab)
}
val trendingStatusesTab = createTabDataFromId(TRENDING_STATUSES)
if (!currentTabs.contains(trendingStatusesTab)) {
addableTabs.add(trendingStatusesTab)
}
addableTabs.add(createTabDataFromId(HASHTAG))
addableTabs.add(createTabDataFromId(LIST))
addTabAdapter.updateData(addableTabs)
binding.maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT)
currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT)
}
@ -429,6 +429,5 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
companion object {
private const val MIN_TAB_COUNT = 2
private const val MAX_TAB_COUNT = 9
}
}

View File

@ -25,9 +25,11 @@ import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
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
@ -76,7 +78,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
AppInjector.init(this)
// Migrate shared preference keys and defaults from version to version.
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0)
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, NEW_INSTALL_SCHEMA_VERSION)
if (oldVersion != SCHEMA_VERSION) {
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
}
@ -87,7 +89,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
// init night mode
val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT)
val theme = sharedPreferences.getString(APP_THEME, AppTheme.DEFAULT.value)
setAppNightMode(theme)
localeManager.setLocale()
@ -130,6 +132,20 @@ class TuskyApplication : Application(), HasAndroidInjector {
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
}
if (oldVersion < 2023072401) {
// The notifications filter / clear options are shown on a menu, not a separate bar,
// the preference to display them is not needed.
editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER)
}
if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) {
// Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and
// didn't have an explicit preference set use the previous default, so the
// theme does not unexpectedly change.
if (!sharedPreferences.contains(APP_THEME)) {
editor.putString(APP_THEME, AppTheme.NIGHT.value)
}
}
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
editor.apply()
}

View File

@ -59,6 +59,8 @@ import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
@ -67,10 +69,13 @@ import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
import javax.inject.Inject
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
class ViewMediaActivity : BaseActivity(), HasAndroidInjector, ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
@ -337,6 +342,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
shareFile(file, mimeType)
}
override fun androidInjector() = androidInjector
companion object {
private const val EXTRA_ATTACHMENTS = "attachments"
private const val EXTRA_ATTACHMENT_INDEX = "index"

View File

@ -47,7 +47,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
val animateAvatar = pm.getBoolean("animateGifAvatars", false)
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar)
}

View File

@ -21,12 +21,10 @@ import android.text.Spanned
import android.text.style.StyleSpan
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
@ -35,33 +33,12 @@ import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.NotificationViewData
class FollowRequestViewHolder(
private val binding: ItemFollowRequestBinding,
private val accountActionListener: AccountActionListener,
private val linkListener: LinkListener,
private val showHeader: Boolean
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
override fun bind(
viewData: NotificationViewData,
payloads: List<*>?,
statusDisplayOptions: StatusDisplayOptions
) {
// Skip updates with payloads. That indicates a timestamp update, and
// this view does not have timestamps.
if (!payloads.isNullOrEmpty()) return
setupWithAccount(
viewData.account,
statusDisplayOptions.animateAvatars,
statusDisplayOptions.animateEmojis,
statusDisplayOptions.showBotOverlay
)
setupActionListener(accountActionListener, viewData.account.id)
}
) : RecyclerView.ViewHolder(binding.root) {
fun setupWithAccount(
account: TimelineAccount,
@ -70,24 +47,12 @@ class FollowRequestViewHolder(
showBotOverlay: Boolean
) {
val wrappedName = account.name.unicodeWrap()
val emojifiedName: CharSequence = wrappedName.emojify(
account.emojis,
itemView,
animateEmojis
)
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
binding.displayNameTextView.text = emojifiedName
if (showHeader) {
val wholeMessage: String = itemView.context.getString(
R.string.notification_follow_request_format,
wrappedName
)
val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName)
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
setSpan(
StyleSpan(Typeface.BOLD),
0,
wrappedName.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}.emojify(account.emojis, itemView, animateEmojis)
}
binding.notificationTextView.visible(showHeader)

View File

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

View File

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

View File

@ -58,6 +58,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.AttachmentHelper;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper;
@ -73,6 +74,7 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.NumberFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@ -125,10 +127,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private final TextView cardDescription;
private final TextView cardUrl;
private final PollAdapter pollAdapter;
protected LinearLayout filteredPlaceholder;
protected TextView filteredPlaceholderLabel;
protected Button filteredPlaceholderShowButton;
protected ConstraintLayout statusContainer;
protected final LinearLayout filteredPlaceholder;
protected final TextView filteredPlaceholderLabel;
protected final Button filteredPlaceholderShowButton;
protected final ConstraintLayout statusContainer;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
@ -139,7 +141,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private final Drawable mediaPreviewUnloaded;
protected StatusBaseViewHolder(View itemView) {
protected StatusBaseViewHolder(@NonNull View itemView) {
super(itemView);
displayName = itemView.findViewById(R.id.status_display_name);
username = itemView.findViewById(R.id.status_username);
@ -203,14 +205,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton));
}
protected void setDisplayName(String name, List<Emoji> customEmojis, StatusDisplayOptions statusDisplayOptions) {
protected void setDisplayName(@NonNull String name, @Nullable List<Emoji> customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
);
displayName.setText(emojifiedName);
}
protected void setUsername(String name) {
protected void setUsername(@Nullable String name) {
Context context = username.getContext();
String usernameText = context.getString(R.string.post_username_format, name);
username.setText(usernameText);
@ -222,10 +224,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status,
@NonNull StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener) {
final @NonNull StatusActionListener listener) {
Status actionable = status.getActionable();
String spoilerText = status.getSpoilerText();
String spoilerText = actionable.getSpoilerText();
List<Emoji> emojis = actionable.getEmojis();
boolean sensitive = !TextUtils.isEmpty(spoilerText);
@ -342,17 +344,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
avatarInset.setVisibility(View.VISIBLE);
avatarInset.setBackground(null);
ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp,
statusDisplayOptions.animateAvatars());
statusDisplayOptions.animateAvatars(), null);
avatarRadius = avatarRadius36dp;
}
ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius,
statusDisplayOptions.animateAvatars());
statusDisplayOptions.animateAvatars(),
Collections.singletonList(new CompositeWithOpaqueBackground(avatar)));
}
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
Status status = statusViewData.getActionable();
@ -591,9 +593,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
protected void setMediaPreviews(
final List<Attachment> attachments,
final @NonNull List<Attachment> attachments,
boolean sensitive,
final StatusActionListener listener,
final @NonNull StatusActionListener listener,
boolean showingContent,
boolean useBlurhash
) {
@ -684,8 +686,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
mediaLabels[index].setText(label);
}
protected void setMediaLabel(List<Attachment> attachments, boolean sensitive,
final StatusActionListener listener, boolean showingContent) {
protected void setMediaLabel(@NonNull List<Attachment> attachments, boolean sensitive,
final @NonNull StatusActionListener listener, boolean showingContent) {
Context context = itemView.getContext();
for (int i = 0; i < mediaLabels.length; i++) {
TextView mediaLabel = mediaLabels[i];
@ -706,7 +708,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
private void setAttachmentClickListener(View view, StatusActionListener listener,
private void setAttachmentClickListener(View view, @NonNull StatusActionListener listener,
int index, Attachment attachment, boolean animateTransition) {
view.setOnClickListener(v -> {
int position = getBindingAdapterPosition();
@ -730,11 +732,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
sensitiveMediaShow.setVisibility(View.GONE);
}
protected void setupButtons(final StatusActionListener listener,
final String accountId,
final String statusContent,
final boolean isNotestock,
StatusDisplayOptions statusDisplayOptions) {
protected void setupButtons(final @NonNull StatusActionListener listener,
final @NonNull String accountId,
final @NonNull String statusContent,
final @NonNull boolean isNotestock,
@NonNull StatusDisplayOptions statusDisplayOptions) {
View.OnClickListener profileButtonClickListener = button -> {
if (isNotestock) {
listener.onViewUrl(accountId, accountId);
@ -851,6 +853,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
listener.onReblog(!buttonState, position);
if(!buttonState) {
reblogButton.playAnimation();
reblogButton.setChecked(true);
}
return true;
});
@ -872,14 +875,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
listener.onFavourite(!buttonState, position);
if(!buttonState) {
favouriteButton.playAnimation();
favouriteButton.setChecked(true);
}
return true;
});
popup.show();
}
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions) {
public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions) {
this.setupWithStatus(status, listener, statusDisplayOptions, null);
}
@ -890,7 +894,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (payloads == null) {
Status actionable = status.getActionable();
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setUsername(status.getUsername());
setUsername(actionable.getAccount().getUsername());
setMetaData(status, statusDisplayOptions, listener);
setIsReply(actionable.getInReplyToId() != null);
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
@ -968,12 +972,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
filteredPlaceholderShowButton.setOnClickListener(view -> {
listener.clearWarningAction(getBindingAdapterPosition());
});
filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition()));
}
protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
protected static boolean hasPreviewableAttachment(@NonNull List<Attachment> attachments) {
for (Attachment attachment : attachments) {
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
return false;
@ -990,11 +992,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
String description = context.getString(R.string.description_status,
actionable.getAccount().getName(),
getContentWarningDescription(context, status),
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
(TextUtils.isEmpty(actionable.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
getReblogDescription(context, status),
status.getUsername(),
actionable.getAccount().getUsername(),
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
@ -1041,14 +1043,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private static CharSequence getContentWarningDescription(Context context,
@NonNull StatusViewData.Concrete status) {
if (!TextUtils.isEmpty(status.getSpoilerText())) {
return context.getString(R.string.description_post_cw, status.getSpoilerText());
if (!TextUtils.isEmpty(status.getActionable().getSpoilerText())) {
return context.getString(R.string.description_post_cw, status.getActionable().getSpoilerText());
} else {
return "";
}
}
protected static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) {
@NonNull
protected static CharSequence getVisibilityDescription(@NonNull Context context, @Nullable Status.Visibility visibility) {
if (visibility == null) {
return "";
@ -1097,7 +1100,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
protected CharSequence getFavsText(Context context, int count) {
@NonNull
protected CharSequence getFavsText(@NonNull Context context, int count) {
if (count > 0) {
String countString = numberFormat.format(count);
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
@ -1106,7 +1110,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
protected CharSequence getReblogsText(Context context, int count) {
@NonNull
protected CharSequence getReblogsText(@NonNull Context context, int count) {
if (count > 0) {
String countString = numberFormat.format(count);
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
@ -1207,11 +1212,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
protected void setupCard(
final StatusViewData.Concrete status,
final @NonNull StatusViewData.Concrete status,
boolean expanded,
final CardViewMode cardViewMode,
final StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener
final @NonNull CardViewMode cardViewMode,
final @NonNull StatusDisplayOptions statusDisplayOptions,
final @NonNull StatusActionListener listener
) {
if (cardView == null) {
return;

View File

@ -36,7 +36,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
public StatusDetailedViewHolder(View view) {
public StatusDetailedViewHolder(@NonNull View view) {
super(view);
reblogs = view.findViewById(R.id.status_reblogs);
favourites = view.findViewById(R.id.status_favourites);
@ -44,7 +44,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
@Override
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
Status status = statusViewData.getActionable();

View File

@ -51,7 +51,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
private final TextView favouritedCountLabel;
private final TextView reblogsCountLabel;
public StatusViewHolder(View itemView) {
public StatusViewHolder(@NonNull View itemView) {
super(itemView);
statusInfo = itemView.findViewById(R.id.status_info);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);

View File

@ -26,12 +26,14 @@ class CacheUpdater @Inject constructor(
eventHub.events.collect { event ->
val accountId = accountManager.activeAccount?.id ?: return@collect
when (event) {
is FavoriteEvent ->
timelineDao.setFavourited(accountId, event.statusId, event.favourite)
is ReblogEvent ->
timelineDao.setReblogged(accountId, event.statusId, event.reblog)
is BookmarkEvent ->
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
is StatusChangedEvent -> {
val status = event.status
timelineDao.update(
accountId = accountId,
status = status,
gson = gson
)
}
is UnfollowEvent ->
timelineDao.removeAllByUser(accountId, event.accountId)
is StatusDeletedEvent ->
@ -40,8 +42,6 @@ class CacheUpdater @Inject constructor(
val pollString = gson.toJson(event.poll)
timelineDao.setVoted(accountId, event.statusId, pollString)
}
is PinEvent ->
timelineDao.setPinned(accountId, event.statusId, event.pinned)
}
}
}

View File

@ -2,13 +2,12 @@ package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import net.accelf.yuito.streaming.Subscription
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Event
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event
data class StatusChangedEvent(val status: Status) : Event
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event
data class UnfollowEvent(val accountId: String) : Event
data class BlockEvent(val accountId: String) : Event
@ -16,13 +15,15 @@ data class MuteEvent(val accountId: String) : Event
data class StatusDeletedEvent(val statusId: String) : Event
data class StatusComposedEvent(val status: Status) : Event
data class StatusScheduledEvent(val status: Status) : Event
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
data class ProfileEditedEvent(val newProfileData: Account) : Event
data class PreferenceChangedEvent(val preferenceKey: String) : Event
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Event
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
data class DomainMuteEvent(val instance: String) : Event
data class AnnouncementReadEvent(val announcementId: String) : Event
data class PinEvent(val statusId: String, val pinned: Boolean) : Event
data class FilterUpdatedEvent(val filterContext: List<String>) : Event
data class NewNotificationsEvent(val accountId: String, val notifications: List<Notification>) : Event
data class ConversationsLoadingEvent(val accountId: String) : Event
data class NotificationsLoadingEvent(val accountId: String) : Event
data class QuickReplyEvent(val status: Status) : Event
data class StreamUpdateEvent(val status: Status, val subscription: Subscription, val streamId: Int) : Event

View File

@ -1,5 +1,7 @@
package com.keylesspalace.tusky.appstore
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.PublishSubject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import javax.inject.Inject
@ -13,7 +15,16 @@ class EventHub @Inject constructor() {
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
val events: Flow<Event> = sharedEventFlow
// TODO remove this old stuff as soon as NotificationsFragment is Kotlin
private val eventsSubject = PublishSubject.create<Event>()
val eventsObservable: Observable<Event> = eventsSubject
suspend fun dispatch(event: Event) {
sharedEventFlow.emit(event)
eventsSubject.onNext(event)
}
fun dispatchOld(event: Event) {
eventsSubject.onNext(event)
}
}

View File

@ -22,9 +22,12 @@ import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.LayerDrawable
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.TextWatcher
import android.text.style.StyleSpan
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
@ -32,10 +35,12 @@ import android.view.View
import android.view.ViewGroup
import androidx.activity.viewModels
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.app.ActivityOptionsCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
@ -48,6 +53,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable
@ -173,9 +179,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false)
animateAvatar = sharedPrefs.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
hideFab = sharedPrefs.getBoolean("fabHide", false)
hideFab = sharedPrefs.getBoolean(PrefKeys.FAB_HIDE, false)
handleWindowInsets()
setupToolbar()
@ -475,10 +481,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
accountFieldAdapter.notifyDataSetChanged()
binding.accountLockedImageView.visible(account.locked)
binding.accountBadgeTextView.visible(account.bot)
updateAccountAvatar()
updateToolbar()
updateBadges()
updateMovedAccount()
updateRemoteAccount()
updateAccountJoinedDate()
@ -491,6 +497,33 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
}
private fun updateBadges() {
binding.accountBadgeContainer.removeAllViews()
val isLight = resources.getBoolean(R.bool.lightNavigationBar)
if (loadedAccount?.bot == true) {
val badgeView = getBadge(getColor(R.color.tusky_grey_50), R.drawable.ic_bot_24dp, getString(R.string.profile_badge_bot_text), isLight)
binding.accountBadgeContainer.addView(badgeView)
}
loadedAccount?.roles?.forEach { role ->
val badgeColor = if (role.color.isNotBlank()) {
Color.parseColor(role.color)
} else {
// sometimes the color is not set for a role, in this case fall back to our default blue
getColor(R.color.tusky_blue)
}
val sb = SpannableStringBuilder("${role.name} ${viewModel.domain}")
sb.setSpan(StyleSpan(Typeface.BOLD), 0, role.name.length, 0)
val badgeView = getBadge(badgeColor, R.drawable.profile_badge_person_24dp, sb, isLight)
binding.accountBadgeContainer.addView(badgeView)
}
}
private fun updateAccountJoinedDate() {
loadedAccount?.let { account ->
try {
@ -766,13 +799,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
loadedAccount?.let { loadedAccount ->
val muteDomain = menu.findItem(R.id.action_mute_domain)
domain = getDomain(loadedAccount.url)
if (domain.isEmpty()) {
when {
// If we can't get the domain, there's no way we can mute it anyway...
menu.removeItem(R.id.action_mute_domain)
} else {
if (blockingDomain) {
// If the account is from our own domain, muting it is no-op
domain.isEmpty() || viewModel.isFromOwnDomain -> {
menu.removeItem(R.id.action_mute_domain)
}
blockingDomain -> {
muteDomain.title = getString(R.string.action_unmute_domain, domain)
} else {
}
else -> {
muteDomain.title = getString(R.string.action_mute_domain, domain)
}
}
@ -994,6 +1030,46 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
}
private fun getBadge(
@ColorInt baseColor: Int,
@DrawableRes icon: Int,
text: CharSequence,
isLight: Boolean
): Chip {
val badge = Chip(this)
// text color with maximum contrast
val textColor = if (isLight) Color.BLACK else Color.WHITE
// badge color with 50% transparency so it blends in with the theme background
val backgroundColor = Color.argb(128, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor))
// a color between the text color and the badge color
val outlineColor = ColorUtils.blendARGB(textColor, baseColor, 0.7f)
// configure the badge
badge.text = text
badge.setTextColor(textColor)
badge.chipStrokeWidth = resources.getDimension(R.dimen.profile_badge_stroke_width)
badge.chipStrokeColor = ColorStateList.valueOf(outlineColor)
badge.setChipIconResource(icon)
badge.isChipIconVisible = true
badge.chipIconSize = resources.getDimension(R.dimen.profile_badge_icon_size)
badge.chipIconTint = ColorStateList.valueOf(outlineColor)
badge.chipBackgroundColor = ColorStateList.valueOf(backgroundColor)
// badge isn't clickable, so disable all related behavior
badge.isClickable = false
badge.isFocusable = false
badge.setEnsureMinTouchTargetSize(false)
// reset some chip defaults so it looks better for our badge usecase
badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding)
badge.iconEndPadding = resources.getDimension(R.dimen.profile_badge_icon_end_padding)
badge.minHeight = resources.getDimensionPixelSize(R.dimen.profile_badge_min_height)
badge.chipMinHeight = resources.getDimension(R.dimen.profile_badge_min_height)
badge.updatePadding(top = 0, bottom = 0)
return badge
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {

View File

@ -19,6 +19,7 @@ import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.getDomain
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -27,7 +28,7 @@ import javax.inject.Inject
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager
accountManager: AccountManager
) : ViewModel() {
val accountData = MutableLiveData<Resource<Account>>()
@ -41,8 +42,16 @@ class AccountViewModel @Inject constructor(
lateinit var accountId: String
var isSelf = false
/** the domain of the viewed account **/
var domain = ""
/** True if the viewed account has the same domain as the active account */
var isFromOwnDomain = false
private var noteUpdateJob: Job? = null
private val activeAccount = accountManager.activeAccount!!
init {
viewModelScope.launch {
eventHub.events.collect { event ->
@ -62,6 +71,9 @@ class AccountViewModel @Inject constructor(
mastodonApi.account(accountId)
.fold(
{ account ->
domain = getDomain(account.url)
isFromOwnDomain = domain == activeAccount.domain
accountData.postValue(Success(account))
isDataLoading = false
isRefreshing.postValue(false)
@ -298,7 +310,7 @@ class AccountViewModel @Inject constructor(
fun setAccountInfo(accountId: String) {
this.accountId = accountId
this.isSelf = accountManager.activeAccount?.accountId == accountId
this.isSelf = activeAccount.accountId == accountId
reload(false)
}

View File

@ -82,13 +82,10 @@ class AccountMediaFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
adapter = AccountMediaGridAdapter(
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
useBlurhash = useBlurhash,
context = view.context,
onAttachmentClickListener = ::onAttachmentClick

View File

@ -24,7 +24,6 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData
import java.util.Random
class AccountMediaGridAdapter(
private val alwaysShowSensitiveMedia: Boolean,
private val useBlurhash: Boolean,
context: Context,
private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit
@ -80,7 +79,7 @@ class AccountMediaGridAdapter(
.into(imageView)
imageView.contentDescription = item.attachment.getFormattedDescription(context)
} else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) {
} else if (item.sensitive && !item.isRevealed) {
overlay.show()
overlay.setImageDrawable(mediaHiddenDrawable)

View File

@ -20,6 +20,7 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import retrofit2.HttpException
@ -27,9 +28,9 @@ import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class)
class AccountMediaRemoteMediator(
private val api: MastodonApi,
private val activeAccount: AccountEntity,
private val viewModel: AccountMediaViewModel
) : RemoteMediator<String, AttachmentViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, AttachmentViewData>
@ -58,7 +59,7 @@ class AccountMediaRemoteMediator(
}
val attachments = statuses.flatMap { status ->
AttachmentViewData.list(status)
AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia ?: false)
}
if (loadType == LoadType.REFRESH) {

View File

@ -21,11 +21,13 @@ import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import javax.inject.Inject
class AccountMediaViewModel @Inject constructor(
private val accountManager: AccountManager,
api: MastodonApi
) : ViewModel() {
@ -35,6 +37,8 @@ class AccountMediaViewModel @Inject constructor(
var currentSource: AccountMediaPagingSource? = null
val activeAccount = accountManager.activeAccount!!
@OptIn(ExperimentalPagingApi::class)
val media = Pager(
config = PagingConfig(
@ -48,7 +52,7 @@ class AccountMediaViewModel @Inject constructor(
currentSource = source
}
},
remoteMediator = AccountMediaRemoteMediator(api, this)
remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this)
).flow
.cachedIn(viewModelScope)

View File

@ -48,7 +48,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
val type = intent.getSerializableExtra(EXTRA_TYPE) as Type
val id: String? = intent.getStringExtra(EXTRA_ID)
val accountLocked: Boolean = intent.getBooleanExtra(EXTRA_ACCOUNT_LOCKED, false)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
@ -66,7 +65,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
}
supportFragmentManager.commit {
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id))
}
}
@ -75,13 +74,11 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
companion object {
private const val EXTRA_TYPE = "type"
private const val EXTRA_ID = "id"
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
fun newIntent(context: Context, type: Type, id: String? = null): Intent {
return Intent(context, AccountListActivity::class.java).apply {
putExtra(EXTRA_TYPE, type)
putExtra(EXTRA_ID, id)
putExtra(EXTRA_ACCOUNT_LOCKED, accountLocked)
}
}
}

View File

@ -61,7 +61,6 @@ import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class AccountListFragment :
@ -107,13 +106,15 @@ class AccountListFragment :
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
val activeAccount = accountManager.activeAccount!!
adapter = when (type) {
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.FOLLOW_REQUESTS -> {
val headerAdapter = FollowRequestsHeaderAdapter(
instanceName = accountManager.activeAccount!!.domain,
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
instanceName = activeAccount.domain,
accountLocked = activeAccount.locked
)
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
@ -330,7 +331,7 @@ class AccountListFragment :
val linkHeader = response.headers()["Link"]
onFetchAccountsSuccess(accountList, linkHeader)
} catch (exception: IOException) {
} catch (exception: Exception) {
onFetchAccountsFailure(exception)
}
}
@ -404,14 +405,12 @@ class AccountListFragment :
private const val TAG = "AccountList" // logging tag
private const val ARG_TYPE = "type"
private const val ARG_ID = "id"
private const val ARG_ACCOUNT_LOCKED = "acc_locked"
fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment {
fun newInstance(type: Type, id: String? = null): AccountListFragment {
return AccountListFragment().apply {
arguments = Bundle(3).apply {
putSerializable(ARG_TYPE, type)
putString(ARG_ID, id)
putBoolean(ARG_ACCOUNT_LOCKED, accountLocked)
}
}
}

View File

@ -44,7 +44,6 @@ class FollowRequestsAdapter(
)
return FollowRequestViewHolder(
binding,
accountActionListener,
linkListener,
showHeader = false
)

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.announcements
import android.annotation.SuppressLint
import android.os.Build
import android.text.SpannableStringBuilder
import android.view.ContextThemeWrapper
@ -29,6 +30,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding
import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.emojify
@ -50,14 +52,19 @@ class AnnouncementAdapter(
private val animateEmojis: Boolean = false
) : RecyclerView.Adapter<BindingHolder<ItemAnnouncementBinding>>() {
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAnnouncementBinding> {
val binding = ItemAnnouncementBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: BindingHolder<ItemAnnouncementBinding>, position: Int) {
val item = items[position]
holder.binding.announcementDate.text = absoluteTimeFormatter.format(item.publishedAt, false)
val text = holder.binding.text
val chips = holder.binding.chipGroup
val addReactionChip = holder.binding.addReactionChip

View File

@ -129,7 +129,7 @@ class AnnouncementsActivity :
is Error -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.errorMessageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
refreshAnnouncements()
}
binding.errorMessageView.show()

View File

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.NotificationManager
import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context
@ -97,8 +96,9 @@ import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
import com.keylesspalace.tusky.util.MentionSpan
import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.getInitialLanguages
@ -212,24 +212,9 @@ class ComposeActivity :
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)
if (notificationId != -1) {
// ComposeActivity was opened from a notification, delete the notification
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationId)
}
activeAccount = accountManager.activeAccount ?: return
// If started from an intent then compose as the account ID from the intent.
// Otherwise use the active account. If null then the user is not logged in,
// and return from the activity.
val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
activeAccount = if (intentAccountId != -1L) {
accountManager.getAccountById(intentAccountId)
} else {
accountManager.activeAccount
} ?: return
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value)
if (theme == "black") {
setTheme(R.style.TuskyDialogActivityBlackTheme)
}
@ -286,7 +271,7 @@ class ComposeActivity :
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
setupComposeField(preferences, viewModel.startingText)
setupDefaultTagViews(preferences)
setupContentWarningField(composeOptions?.contentWarning)
@ -536,7 +521,12 @@ class ComposeActivity :
if (throwable is UploadServerError) {
displayTransientMessage(throwable.errorMessage)
} else {
displayTransientMessage(R.string.error_media_upload_sending)
displayTransientMessage(
getString(
R.string.error_media_upload_sending_fmt,
throwable.message
)
)
}
}
}
@ -662,7 +652,7 @@ class ComposeActivity :
a.getDimensionPixelSize(0, 1)
}
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
loadAvatar(
activeAccount.profilePictureUrl,
binding.composeAvatar,
@ -1148,9 +1138,28 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item)
}
private fun sanitizePickMediaDescription(description: String?): String? {
if (description == null) {
return null
}
// The Gboard android keyboard attaches this text whenever the user
// pastes something from the keyboard's suggestion bar.
// Due to different end user locales, the exact text may vary, but at
// least in version 13.4.08, all of the translations contained the
// string "Gboard".
if ("Gboard" in description) {
return null
}
return description
}
private fun pickMedia(uri: Uri, description: String? = null) {
var sanitizedDescription = sanitizePickMediaDescription(description)
lifecycleScope.launch {
viewModel.pickMedia(uri, description).onFailure { throwable ->
viewModel.pickMedia(uri, sanitizedDescription).onFailure { throwable ->
val errorString = when (throwable) {
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
@ -1435,8 +1444,6 @@ class ComposeActivity :
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
private const val VISIBILITY_KEY = "VISIBILITY"
private const val SCHEDULED_TIME_KEY = "SCHEDULE"
@ -1450,26 +1457,15 @@ class ComposeActivity :
/**
* @param options ComposeOptions to configure the ComposeActivity
* @param notificationId the id of the notification that starts the Activity
* @param accountId the id of the account to compose with, null for the current account
* @return an Intent to start the ComposeActivity
*/
@JvmStatic
@JvmOverloads
fun startIntent(
context: Context,
options: ComposeOptions,
notificationId: Int? = null,
accountId: Long? = null
options: ComposeOptions
): Intent {
return Intent(context, ComposeActivity::class.java).apply {
putExtra(COMPOSE_OPTIONS_EXTRA, options)
if (notificationId != null) {
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
}
if (accountId != null) {
putExtra(ACCOUNT_ID_EXTRA, accountId)
}
}
}

View File

@ -37,7 +37,6 @@ import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -51,7 +50,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@OptIn(FlowPreview::class)
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
@ -106,7 +104,7 @@ class ComposeViewModel @Inject constructor(
val domain = accountManager.activeAccount?.domain!!
lateinit var composeKind: ComposeKind
private lateinit var composeKind: ComposeKind
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null
@ -288,7 +286,7 @@ class ComposeViewModel @Inject constructor(
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
media.value.forEach { item ->
for (item in media.value) {
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
mediaFocus.add(item.focus)

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.compose
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.media.MediaMetadataRetriever
@ -246,6 +247,7 @@ class MediaUploader @Inject constructor(
private val contentResolver = context.contentResolver
@SuppressLint("Recycle") // stream is closed in ProgressRequestBody
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
return callbackFlow {
var mimeType = contentResolver.getType(media.uri)

View File

@ -75,10 +75,6 @@ class AddPollOptionsAdapter(
}
private fun validateInput(): Boolean {
if (options.contains("") || options.distinct().size != options.size) {
return false
}
return true
return !(options.contains("") || options.distinct().size != options.size)
}
}

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.compose.dialog
import android.app.Dialog
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.Uri
@ -26,7 +25,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import android.widget.LinearLayout
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
@ -36,6 +35,8 @@ import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
@ -44,14 +45,29 @@ class CaptionDialog : DialogFragment() {
private lateinit var listener: Listener
private lateinit var input: EditText
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
private val binding by viewBinding(DialogImageDescriptionBinding::bind)
val binding = DialogImageDescriptionBinding.inflate(layoutInflater)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
input.setText(it)
}
return inflater.inflate(R.layout.dialog_image_description, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
input = binding.imageDescriptionText
val imageView = binding.imageDescriptionView
imageView.maximumScale = 6f
imageView.maxZoom = 6f
input.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,
@ -61,20 +77,19 @@ class CaptionDialog : DialogFragment() {
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
binding.cancelButton.setOnClickListener {
dismiss()
}
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
val dialog = AlertDialog.Builder(context)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
listener.onUpdateDescription(localId, input.text.toString())
}
.setNegativeButton(android.R.string.cancel, null)
.create()
binding.okButton.setOnClickListener {
listener.onUpdateDescription(localId, input.text.toString())
dismiss()
}
isCancelable = true
val window = dialog.window
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
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)
@ -90,9 +105,23 @@ class CaptionDialog : DialogFragment() {
) {
imageView.setImageDrawable(resource)
}
})
return dialog
override fun onLoadFailed(errorDrawable: Drawable?) {
super.onLoadFailed(errorDrawable)
imageView.hide()
}
})
}
override fun onStart() {
super.onStart()
dialog?.apply {
window?.setLayout(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
}
override fun onSaveInstanceState(outState: Bundle) {
@ -100,17 +129,6 @@ class CaptionDialog : DialogFragment() {
super.onSaveInstanceState(outState)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
input.setText(it)
}
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onAttach(context: Context) {
super.onAttach(context)
listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener")

View File

@ -49,12 +49,12 @@ fun <T> T.makeFocusDialog(
.load(previewUri)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>?, p3: Boolean): Boolean {
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>, p3: Boolean): Boolean {
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
val width = resource!!.intrinsicWidth
override fun onResourceReady(resource: Drawable, model: Any, target: Target<Drawable?>?, dataSource: DataSource, isFirstResource: Boolean): Boolean {
val width = resource.intrinsicWidth
val height = resource.intrinsicHeight
dialogBinding.focusIndicator.setImageSize(width, height)

View File

@ -57,7 +57,9 @@ class ComposeScheduleView
).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private var scheduleDateTime: Calendar? = null
/** The date/time the user has chosen to schedule the status, in UTC */
private var scheduleDateTimeUtc: Calendar? = null
init {
binding.scheduledDateTime.setOnClickListener { openPickDateDialog() }
@ -71,13 +73,13 @@ class ComposeScheduleView
}
private fun updateScheduleUi() {
if (scheduleDateTime == null) {
if (scheduleDateTimeUtc == null) {
binding.scheduledDateTime.text = ""
binding.invalidScheduleWarning.visibility = GONE
return
}
val scheduled = scheduleDateTime!!.time
val scheduled = scheduleDateTimeUtc!!.time
binding.scheduledDateTime.text = String.format(
"%s %s",
dateFormat.format(scheduled),
@ -98,21 +100,37 @@ class ComposeScheduleView
}
fun resetSchedule() {
scheduleDateTime = null
scheduleDateTimeUtc = null
updateScheduleUi()
}
fun openPickDateDialog() {
val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000
// The earliest point in time the calendar should display. Start with current date/time
val earliest = calendar().apply {
// Add the minimum scheduling interval. This may roll the calendar over to the
// next day (e.g. if the current time is 23:57).
add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS)
// Clear out the time components, so it's midnight
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
val calendarConstraints = CalendarConstraints.Builder()
.setValidator(
DateValidatorPointForward.from(yesterday)
)
.setValidator(DateValidatorPointForward.from(earliest.timeInMillis))
.build()
initializeSuggestedTime()
// Work around a misfeature in MaterialDatePicker. The `selection` is treated as
// millis-from-epoch, in UTC, which is good. However, it is also *displayed* in UTC
// instead of converting to the user's local timezone.
//
// So we have to add the TZ offset before setting it in the picker
val tzOffset = TimeZone.getDefault().getOffset(scheduleDateTimeUtc!!.timeInMillis)
val picker = MaterialDatePicker.Builder
.datePicker()
.setSelection(scheduleDateTime!!.timeInMillis)
.setSelection(scheduleDateTimeUtc!!.timeInMillis + tzOffset)
.setCalendarConstraints(calendarConstraints)
.build()
picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) }
@ -129,11 +147,12 @@ class ComposeScheduleView
private fun openPickTimeDialog() {
val pickerBuilder = MaterialTimePicker.Builder()
scheduleDateTime?.let {
scheduleDateTimeUtc?.let {
pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY])
.setMinute(it[Calendar.MINUTE])
}
pickerBuilder.setTitleText(dateFormat.format(scheduleDateTimeUtc!!.timeInMillis))
pickerBuilder.setTimeFormat(getTimeFormat(context))
val picker = pickerBuilder.build()
@ -154,7 +173,7 @@ class ComposeScheduleView
fun setDateTime(scheduledAt: String?) {
val date = getDateTime(scheduledAt) ?: return
initializeSuggestedTime()
scheduleDateTime!!.time = date
scheduleDateTimeUtc!!.time = date
updateScheduleUi()
}
@ -180,24 +199,24 @@ class ComposeScheduleView
// see https://github.com/material-components/material-components-android/issues/882
newDate.timeZone = TimeZone.getTimeZone("UTC")
newDate.timeInMillis = selection
scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
openPickTimeDialog()
}
private fun onTimeSet(hourOfDay: Int, minute: Int) {
initializeSuggestedTime()
scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay)
scheduleDateTime?.set(Calendar.MINUTE, minute)
scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay)
scheduleDateTimeUtc?.set(Calendar.MINUTE, minute)
updateScheduleUi()
listener?.onTimeSet(time)
}
val time: String?
get() = scheduleDateTime?.time?.let { iso8601.format(it) }
get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) }
private fun initializeSuggestedTime() {
if (scheduleDateTime == null) {
scheduleDateTime = calendar().apply {
if (scheduleDateTimeUtc == null) {
scheduleDateTimeUtc = calendar().apply {
add(Calendar.MINUTE, 15)
}
}

View File

@ -78,7 +78,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
if (payloads == null) {
TimelineAccount account = status.getAccount();
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener);
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
ImageView avatarView = avatars[i];
if (i < accounts.size()) {
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
avatarRadius48dp, statusDisplayOptions.animateAvatars());
avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
avatarView.setVisibility(View.VISIBLE);
} else {
avatarView.setVisibility(View.GONE);

View File

@ -40,6 +40,7 @@ import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
@ -55,6 +56,7 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.isAnyLoading
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
@ -65,6 +67,7 @@ import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@ -100,14 +103,14 @@ class ConversationsFragment :
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
useBlurhash = preferences.getBoolean("useBlurhash", true),
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
@ -130,12 +133,19 @@ class ConversationsFragment :
binding.statusView.hide()
binding.progressBar.hide()
if (loadState.isAnyLoading()) {
runBlocking {
eventHub.dispatch(ConversationsLoadingEvent(accountManager.activeAccount?.accountId ?: ""))
}
}
if (adapter.itemCount == 0) {
when (loadState.refresh) {
is LoadState.NotLoading -> {
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
binding.statusView.showHelp(R.string.help_empty_conversations)
}
}
is LoadState.Error -> {

View File

@ -1,15 +1,14 @@
package com.keylesspalace.tusky.components.instancemute
package com.keylesspalace.tusky.components.domainblocks
import android.os.Bundle
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class InstanceListActivity : BaseActivity(), HasAndroidInjector {
class DomainBlocksActivity : BaseActivity(), HasAndroidInjector {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -28,7 +27,7 @@ class InstanceListActivity : BaseActivity(), HasAndroidInjector {
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, InstanceListFragment())
.replace(R.id.fragment_container, DomainBlocksFragment())
.commit()
}

View File

@ -0,0 +1,27 @@
package com.keylesspalace.tusky.components.domainblocks
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import com.keylesspalace.tusky.components.followedtags.FollowedTagsAdapter.Companion.STRING_COMPARATOR
import com.keylesspalace.tusky.databinding.ItemBlockedDomainBinding
import com.keylesspalace.tusky.util.BindingHolder
class DomainBlocksAdapter(
private val onUnmute: (String) -> Unit
) : PagingDataAdapter<String, BindingHolder<ItemBlockedDomainBinding>>(STRING_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemBlockedDomainBinding> {
val binding = ItemBlockedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemBlockedDomainBinding>, position: Int) {
getItem(position)?.let { instance ->
holder.binding.blockedDomain.text = instance
holder.binding.blockedDomainUnblock.setOnClickListener {
onUnmute(instance)
}
}
}
}

View File

@ -0,0 +1,92 @@
package com.keylesspalace.tusky.components.domainblocks
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.FragmentDomainBlocksBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val binding by viewBinding(FragmentDomainBlocksBinding::bind)
private val viewModel: DomainBlocksViewModel by viewModels { viewModelFactory }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = DomainBlocksAdapter(viewModel::unblock)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
binding.recyclerView.adapter = adapter
binding.recyclerView.layoutManager = LinearLayoutManager(view.context)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiEvents.collect { event ->
showSnackbar(event)
}
}
lifecycleScope.launch {
viewModel.domainPager.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
adapter.addLoadStateListener { loadState ->
binding.progressBar.visible(loadState.refresh == LoadState.Loading && adapter.itemCount == 0)
if (loadState.refresh is LoadState.Error) {
binding.recyclerView.hide()
binding.messageView.show()
val errorState = loadState.refresh as LoadState.Error
binding.messageView.setup(errorState.error) { adapter.retry() }
Log.w(TAG, "error loading blocked domains", errorState.error)
} else if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) {
binding.recyclerView.hide()
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
} else {
binding.recyclerView.show()
binding.messageView.hide()
}
}
}
private fun showSnackbar(event: SnackbarEvent) {
val message = if (event.throwable == null) {
getString(event.message, event.domain)
} else {
Log.w(TAG, event.throwable)
val error = event.throwable.localizedMessage ?: getString(R.string.ui_error_unknown)
getString(event.message, event.domain, error)
}
Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG)
.setTextMaxLines(5)
.setAction(event.actionText, event.action)
.show()
}
companion object {
private const val TAG = "DomainBlocksFragment"
}
}

View File

@ -0,0 +1,19 @@
package com.keylesspalace.tusky.components.domainblocks
import androidx.paging.PagingSource
import androidx.paging.PagingState
class DomainBlocksPagingSource(
private val domains: List<String>,
private val nextKey: String?
) : PagingSource<String, String>() {
override fun getRefreshKey(state: PagingState<String, String>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, String> {
return if (params is LoadParams.Refresh) {
LoadResult.Page(domains, null, nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

View File

@ -0,0 +1,57 @@
package com.keylesspalace.tusky.components.domainblocks
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalPagingApi::class)
class DomainBlocksRemoteMediator(
private val api: MastodonApi,
private val repository: DomainBlocksRepository
) : RemoteMediator<String, String>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, String>
): MediatorResult {
return try {
val response = request(loadType)
?: return MediatorResult.Success(endOfPaginationReached = true)
return applyResponse(response)
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
private suspend fun request(loadType: LoadType): Response<List<String>>? {
return when (loadType) {
LoadType.PREPEND -> null
LoadType.APPEND -> api.domainBlocks(maxId = repository.nextKey)
LoadType.REFRESH -> {
repository.nextKey = null
repository.domains.clear()
api.domainBlocks()
}
}
}
private fun applyResponse(response: Response<List<String>>): MediatorResult {
val tags = response.body()
if (!response.isSuccessful || tags == null) {
return MediatorResult.Error(HttpException(response))
}
val links = HttpHeaderLink.parse(response.headers()["Link"])
repository.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
repository.domains.addAll(tags)
repository.invalidate()
return MediatorResult.Success(endOfPaginationReached = repository.nextKey == null)
}
}

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.components.domainblocks
import androidx.paging.ExperimentalPagingApi
import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.onSuccess
import com.keylesspalace.tusky.network.MastodonApi
import javax.inject.Inject
class DomainBlocksRepository @Inject constructor(
private val api: MastodonApi
) {
val domains: MutableList<String> = mutableListOf()
var nextKey: String? = null
private var factory = InvalidatingPagingSourceFactory {
DomainBlocksPagingSource(domains.toList(), nextKey)
}
@OptIn(ExperimentalPagingApi::class)
val domainPager = Pager(
config = PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE),
remoteMediator = DomainBlocksRemoteMediator(api, this),
pagingSourceFactory = factory
).flow
/** Invalidate the active paging source, see [PagingSource.invalidate] */
fun invalidate() {
factory.invalidate()
}
suspend fun block(domain: String): NetworkResult<Unit> {
return api.blockDomain(domain).onSuccess {
domains.add(domain)
factory.invalidate()
}
}
suspend fun unblock(domain: String): NetworkResult<Unit> {
return api.unblockDomain(domain).onSuccess {
domains.remove(domain)
factory.invalidate()
}
}
companion object {
private const val PAGE_SIZE = 20
}
}

View File

@ -0,0 +1,72 @@
package com.keylesspalace.tusky.components.domainblocks
import android.view.View
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.R
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
class DomainBlocksViewModel @Inject constructor(
private val repo: DomainBlocksRepository
) : ViewModel() {
val domainPager = repo.domainPager.cachedIn(viewModelScope)
val uiEvents = MutableSharedFlow<SnackbarEvent>()
fun block(domain: String) {
viewModelScope.launch {
repo.block(domain).onFailure { e ->
uiEvents.emit(
SnackbarEvent(
message = R.string.error_blocking_domain,
domain = domain,
throwable = e,
actionText = R.string.action_retry,
action = { block(domain) }
)
)
}
}
}
fun unblock(domain: String) {
viewModelScope.launch {
repo.unblock(domain).fold({
uiEvents.emit(
SnackbarEvent(
message = R.string.confirmation_domain_unmuted,
domain = domain,
throwable = null,
actionText = R.string.action_undo,
action = { block(domain) }
)
)
}, { e ->
uiEvents.emit(
SnackbarEvent(
message = R.string.error_unblocking_domain,
domain = domain,
throwable = e,
actionText = R.string.action_retry,
action = { unblock(domain) }
)
)
})
}
}
}
class SnackbarEvent(
@StringRes val message: Int,
val domain: String,
val throwable: Throwable?,
@StringRes val actionText: Int,
val action: (View) -> Unit
)

View File

@ -35,11 +35,11 @@ import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject
class DraftsActivity : BaseActivity(), DraftActionListener {
@ -131,7 +131,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
Log.w(TAG, "failed loading reply information", throwable)
if (throwable is HttpException && throwable.code() == 404) {
if (throwable.isHttpNotFound()) {
// the original status to which a reply was drafted has been deleted
// let's open the ComposeActivity without reply information
Toast.makeText(context, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show()

View File

@ -1,6 +1,7 @@
package com.keylesspalace.tusky.components.filters
import android.content.Context
import android.content.DialogInterface.BUTTON_POSITIVE
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
@ -18,16 +19,17 @@ import com.google.android.material.switchmaterial.SwitchMaterial
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding
import com.keylesspalace.tusky.databinding.DialogFilterBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
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
@ -81,7 +83,11 @@ class EditFilterActivity : BaseActivity() {
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() }
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
binding.filterDeleteButton.setOnClickListener {
lifecycleScope.launch {
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter()
}
}
binding.filterDeleteButton.visible(originalFilter != null)
for (switch in contextSwitches.keys) {
@ -259,6 +265,9 @@ class EditFilterActivity : BaseActivity() {
lifecycleScope.launch {
if (viewModel.saveChanges(this@EditFilterActivity)) {
finish()
// Possibly affected contexts: any context affected by the original filter OR any context affected by the updated filter
val affectedContexts = viewModel.contexts.value.map { it.kind }.union(originalFilter?.context ?: listOf()).distinct()
eventHub.dispatch(FilterUpdatedEvent(affectedContexts))
} else {
Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show()
}
@ -273,7 +282,7 @@ class EditFilterActivity : BaseActivity() {
finish()
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
if (throwable.isHttpNotFound()) {
api.deleteFilterV1(filter.id).fold(
{
finish()

View File

@ -8,9 +8,9 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import javax.inject.Inject
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() {
@ -108,7 +108,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
},
{ throwable ->
return (
throwable is HttpException && throwable.code() == 404 &&
throwable.isHttpNotFound() &&
// Endpoint not found, fall back to v1 api
createFilterV1(contexts, expiresInSeconds)
)
@ -141,7 +141,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
return results.none { it.isFailure }
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
if (throwable.isHttpNotFound()) {
// Endpoint not found, fall back to v1 api
if (updateFilterV1(contexts, expiresInSeconds)) {
return true

View File

@ -15,24 +15,15 @@
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
package com.keylesspalace.tusky.components.filters
import android.view.ViewGroup
import androidx.paging.LoadState
import androidx.paging.LoadStateAdapter
import android.app.Activity
import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.await
/** Show load state and retry options when loading notifications */
class NotificationsLoadStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<NotificationsLoadStateViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
loadState: LoadState
): NotificationsLoadStateViewHolder {
return NotificationsLoadStateViewHolder.create(parent, retry)
}
override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
}
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_delete_filter_text, filterTitle))
.setCancelable(true)
.create()
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)

View File

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.components.filters
import android.content.DialogInterface.BUTTON_POSITIVE
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
@ -60,18 +61,19 @@ class FiltersActivity : BaseActivity(), FiltersListener {
when (state.loadingState) {
FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide()
FiltersViewModel.LoadingState.ERROR_NETWORK -> {
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) {
loadFilters()
}
binding.messageView.show()
}
FiltersViewModel.LoadingState.ERROR_OTHER -> {
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
loadFilters()
}
binding.messageView.show()
}
FiltersViewModel.LoadingState.LOADED -> {
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
if (state.filters.isEmpty()) {
binding.messageView.setup(
R.drawable.elephant_friend_empty,
@ -81,7 +83,6 @@ class FiltersActivity : BaseActivity(), FiltersListener {
binding.messageView.show()
} else {
binding.messageView.hide()
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
}
}
}
@ -104,7 +105,11 @@ class FiltersActivity : BaseActivity(), FiltersListener {
}
override fun deleteFilter(filter: Filter) {
viewModel.deleteFilter(filter, binding.root)
lifecycleScope.launch {
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) {
viewModel.deleteFilter(filter, binding.root)
}
}
}
override fun updateFilter(updatedFilter: Filter) {

View File

@ -9,10 +9,10 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject
class FiltersViewModel @Inject constructor(
@ -38,14 +38,13 @@ class FiltersViewModel @Inject constructor(
this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED)
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
if (throwable.isHttpNotFound()) {
api.getFiltersV1().fold(
{ filters ->
this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
},
{ throwable ->
{ _ ->
// TODO log errors (also below)
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
}
)
@ -68,7 +67,7 @@ class FiltersViewModel @Inject constructor(
}
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
if (throwable.isHttpNotFound()) {
api.deleteFilterV1(filter.id).fold(
{
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)

View File

@ -28,5 +28,6 @@ data class InstanceInfo(
val maxMediaAttachments: Int,
val maxFields: Int,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
val maxFieldValueLength: Int?,
val version: String?
)

View File

@ -25,6 +25,7 @@ import com.keylesspalace.tusky.db.EmojisEntity
import com.keylesspalace.tusky.db.InstanceInfoEntity
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
@ -62,6 +63,41 @@ class InstanceInfoRepository @Inject constructor(
*/
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) {
api.getInstance()
.fold(
{ instance ->
val instanceEntity = InstanceInfoEntity(
instance = instanceName,
maximumTootCharacters = instance.configuration.statuses?.maxCharacters ?: DEFAULT_CHARACTER_LIMIT,
maxPollOptions = instance.configuration.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT,
maxPollOptionLength = instance.configuration.polls?.maxCharactersPerOption ?: DEFAULT_MAX_OPTION_LENGTH,
minPollDuration = instance.configuration.polls?.minExpirationSeconds ?: DEFAULT_MIN_POLL_DURATION,
maxPollDuration = instance.configuration.polls?.maxExpirationSeconds ?: DEFAULT_MAX_POLL_DURATION,
charactersReservedPerUrl = instance.configuration.statuses?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
version = instance.version,
videoSizeLimit = instance.configuration.mediaAttachments?.videoSizeLimitBytes?.toInt() ?: DEFAULT_VIDEO_SIZE_LIMIT,
imageSizeLimit = instance.configuration.mediaAttachments?.imageSizeLimitBytes?.toInt() ?: DEFAULT_IMAGE_SIZE_LIMIT,
imageMatrixLimit = instance.configuration.mediaAttachments?.imagePixelCountLimit?.toInt() ?: DEFAULT_IMAGE_MATRIX_LIMIT,
maxMediaAttachments = instance.configuration.statuses?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength,
)
dao.upsert(instanceEntity)
instanceEntity
},
{ throwable ->
if (throwable.isHttpNotFound()) {
getInstanceInfoV1()
} else {
Log.w(TAG, "failed to instance, falling back to cache and default values", throwable)
getCachedInstanceInfoEntity()
}
}
).toInstanceInfo()
}
private suspend fun getInstanceInfoV1(): InstanceInfoEntity? = withContext(Dispatchers.IO) {
api.getInstanceV1()
.fold(
{ instance ->
val instanceEntity = InstanceInfoEntity(
@ -79,7 +115,7 @@ class InstanceInfoRepository @Inject constructor(
maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments,
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength,
)
dao.upsert(instanceEntity)
instanceEntity
@ -88,7 +124,7 @@ class InstanceInfoRepository @Inject constructor(
Log.w(TAG, "failed to instance, falling back to cache and default values", throwable)
getCachedInstanceInfoEntity()
}
).toInstanceInfo()
)
}
private suspend fun getCachedInstanceInfoEntity(): InstanceInfoEntity? =
@ -149,6 +185,7 @@ class InstanceInfoRepository @Inject constructor(
maxFields = this?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
maxFieldNameLength = this?.maxFieldNameLength,
maxFieldValueLength = this?.maxFieldValueLength,
version = this?.version,
)
}
}

View File

@ -1,56 +0,0 @@
package com.keylesspalace.tusky.components.instancemute.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener
import com.keylesspalace.tusky.databinding.ItemMutedDomainBinding
import com.keylesspalace.tusky.util.BindingHolder
class DomainMutesAdapter(
private val actionListener: InstanceActionListener
) : RecyclerView.Adapter<BindingHolder<ItemMutedDomainBinding>>() {
var instances: MutableList<String> = mutableListOf()
var bottomLoading: Boolean = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemMutedDomainBinding> {
val binding = ItemMutedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemMutedDomainBinding>, position: Int) {
val instance = instances[position]
holder.binding.mutedDomain.text = instance
holder.binding.mutedDomainUnmute.setOnClickListener {
actionListener.mute(false, instance, holder.bindingAdapterPosition)
}
}
override fun getItemCount(): Int {
var count = instances.size
if (bottomLoading) {
++count
}
return count
}
fun addItems(newInstances: List<String>) {
val end = instances.size
instances.addAll(newInstances)
notifyItemRangeInserted(end, instances.size)
}
fun addItem(instance: String) {
instances.add(instance)
notifyItemInserted(instances.size)
}
fun removeItem(position: Int) {
if (position >= 0 && position < instances.size) {
instances.removeAt(position)
notifyItemRemoved(position)
}
}
}

View File

@ -1,158 +0,0 @@
package com.keylesspalace.tusky.components.instancemute.fragment
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.calladapter.networkresult.fold
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter
import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener
import com.keylesspalace.tusky.databinding.FragmentInstanceListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import javax.inject.Inject
class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
@Inject
lateinit var api: MastodonApi
private val binding by viewBinding(FragmentInstanceListBinding::bind)
private var fetching = false
private var bottomId: String? = null
private var adapter = DomainMutesAdapter(this)
private lateinit var scrollListener: EndlessOnScrollListener
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
binding.recyclerView.adapter = adapter
val layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager
scrollListener = object : EndlessOnScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
if (bottomId != null) {
fetchInstances(bottomId)
}
}
}
binding.recyclerView.addOnScrollListener(scrollListener)
fetchInstances()
}
override fun mute(mute: Boolean, instance: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
if (mute) {
api.blockDomain(instance).fold({
adapter.addItem(instance)
}, { e ->
Log.e(TAG, "Error muting domain $instance", e)
})
} else {
api.unblockDomain(instance).fold({
adapter.removeItem(position)
Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
mute(true, instance, position)
}
.show()
}, { e ->
Log.e(TAG, "Error unmuting domain $instance", e)
})
}
}
}
private fun fetchInstances(id: String? = null) {
if (fetching) {
return
}
fetching = true
binding.instanceProgressBar.show()
if (id != null) {
binding.recyclerView.post { adapter.bottomLoading = true }
}
api.domainBlocks(id, bottomId)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ response ->
val instances = response.body()
if (response.isSuccessful && instances != null) {
onFetchInstancesSuccess(instances, response.headers()["Link"])
} else {
onFetchInstancesFailure(Exception(response.message()))
}
},
{ throwable ->
onFetchInstancesFailure(throwable)
}
)
}
private fun onFetchInstancesSuccess(instances: List<String>, linkHeader: String?) {
adapter.bottomLoading = false
binding.instanceProgressBar.hide()
val links = HttpHeaderLink.parse(linkHeader)
val next = HttpHeaderLink.findByRelationType(links, "next")
val fromId = next?.uri?.getQueryParameter("max_id")
adapter.addItems(instances)
bottomId = fromId
fetching = false
if (adapter.itemCount == 0) {
binding.messageView.show()
binding.messageView.setup(
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
)
} else {
binding.messageView.hide()
}
}
private fun onFetchInstancesFailure(throwable: Throwable) {
fetching = false
binding.instanceProgressBar.hide()
Log.e(TAG, "Fetch failure", throwable)
if (adapter.itemCount == 0) {
binding.messageView.show()
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchInstances(null)
}
}
}
companion object {
private const val TAG = "InstanceList" // logging tag
}
}

View File

@ -1,5 +0,0 @@
package com.keylesspalace.tusky.components.instancemute.interfaces
interface InstanceActionListener {
fun mute(mute: Boolean, instance: String, position: Int)
}

View File

@ -99,7 +99,7 @@ sealed class LoginResult : Parcelable {
data class Err(val errorMessage: String) : LoginResult()
@Parcelize
object Cancel : LoginResult()
data object Cancel : LoginResult()
}
/** Activity to do Oauth process using WebView. */

View File

@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -36,11 +37,25 @@ class LoginWebViewViewModel @Inject constructor(
if (this.domain == null) {
this.domain = domain
viewModelScope.launch {
api.getInstance(domain).fold({ instance ->
instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty()
}, { throwable ->
Log.w("LoginWebViewViewModel", "failed to load instance info", throwable)
})
api.getInstance().fold(
{ instance ->
instanceRules.value = instance.rules.map { rule -> rule.text }
},
{ throwable ->
if (throwable.isHttpNotFound()) {
api.getInstanceV1(domain).fold(
{ instance ->
instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty()
},
{ throwable ->
Log.w("LoginWebViewViewModel", "failed to load instance info", throwable)
}
)
} else {
Log.w("LoginWebViewViewModel", "failed to load instance info", throwable)
}
}
)
}
}
}

View File

@ -1,111 +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.components.notifications
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowBinding
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.viewdata.NotificationViewData
class FollowViewHolder(
private val binding: ItemFollowBinding,
private val notificationActionListener: NotificationActionListener,
private val linkListener: LinkListener
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_42dp
)
override fun bind(
viewData: NotificationViewData,
payloads: List<*>?,
statusDisplayOptions: StatusDisplayOptions
) {
// Skip updates with payloads. That indicates a timestamp update, and
// this view does not have timestamps.
if (!payloads.isNullOrEmpty()) return
setMessage(
viewData.account,
viewData.type === Notification.Type.SIGN_UP,
statusDisplayOptions.animateAvatars,
statusDisplayOptions.animateEmojis
)
setupButtons(notificationActionListener, viewData.account.id)
}
private fun setMessage(
account: TimelineAccount,
isSignUp: Boolean,
animateAvatars: Boolean,
animateEmojis: Boolean
) {
val context = binding.notificationText.context
val format =
context.getString(
if (isSignUp) {
R.string.notification_sign_up_format
} else {
R.string.notification_follow_format
}
)
val wrappedDisplayName = account.name.unicodeWrap()
val wholeMessage = String.format(format, wrappedDisplayName)
val emojifiedMessage =
wholeMessage.emojify(
account.emojis,
binding.notificationText,
animateEmojis
)
binding.notificationText.text = emojifiedMessage
val username = context.getString(R.string.post_username_format, account.username)
binding.notificationUsername.text = username
val emojifiedDisplayName = wrappedDisplayName.emojify(
account.emojis,
binding.notificationUsername,
animateEmojis
)
binding.notificationDisplayName.text = emojifiedDisplayName
loadAvatar(
account.avatar,
binding.notificationAvatar,
avatarRadius42dp,
animateAvatars
)
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(
account.emojis,
binding.notificationAccountNote,
animateEmojis
)
setClickableText(binding.notificationAccountNote, emojifiedNote, emptyList(), null, linkListener)
}
private fun setupButtons(listener: NotificationActionListener, accountId: String) {
binding.root.setOnClickListener { listener.onViewAccount(accountId) }
}
}

View File

@ -4,18 +4,38 @@ import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.annotation.WorkerThread
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.isLessThan
import kotlinx.coroutines.delay
import javax.inject.Inject
import kotlin.math.min
import kotlin.time.Duration.Companion.milliseconds
/** Models next/prev links from the "Links" header in an API response */
data class Links(val next: String?, val prev: String?) {
companion object {
fun from(linkHeader: String?): Links {
val links = HttpHeaderLink.parse(linkHeader)
return Links(
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
"max_id"
),
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
"min_id"
)
)
}
}
}
/**
* Fetch Mastodon notifications and show Android notifications, with summaries, for them.
*
@ -28,7 +48,8 @@ import kotlin.time.Duration.Companion.milliseconds
class NotificationFetcher @Inject constructor(
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val context: Context
private val context: Context,
private val eventHub: EventHub
) {
suspend fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) {
@ -42,6 +63,10 @@ class NotificationFetcher @Inject constructor(
.sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
.toMutableList()
// TODO do this before filter above? But one could argue that (for example) a tab badge is also a notification
// (and should therefore adhere to the notification config).
eventHub.dispatch(NewNotificationsEvent(account.accountId, notifications))
// There's a maximum limit on the number of notifications an Android app
// can display. If the total number of notifications (current notifications,
// plus new ones) exceeds this then some newer notifications will be dropped.
@ -65,23 +90,29 @@ class NotificationFetcher @Inject constructor(
}
}
val notificationsByType = notifications.groupBy { it.type }
// Make and send the new notifications
// TODO: Use the batch notification API available in NotificationManagerCompat
// 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01)
// when it is released.
notifications.forEachIndexed { index, notification ->
val androidNotification = NotificationHelper.make(
context,
notificationManager,
notification,
account,
index == 0
)
notificationManager.notify(notification.id, account.id.toInt(), androidNotification)
// Android will rate limit / drop notifications if they're posted too
// quickly. There is no indication to the user that this happened.
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
delay(1000.milliseconds)
notificationsByType.forEach { notificationsGroup ->
notificationsGroup.value.forEach { notification ->
val androidNotification = NotificationHelper.make(
context,
notificationManager,
notification,
account,
notificationsGroup.value.size == 1
)
notificationManager.notify(notification.id, account.id.toInt(), androidNotification)
// Android will rate limit / drop notifications if they're posted too
// quickly. There is no indication to the user that this happened.
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
delay(1000.milliseconds)
}
}
NotificationHelper.updateSummaryNotifications(

View File

@ -85,13 +85,6 @@ public class NotificationHelper {
/** Dynamic notification IDs start here */
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
/**
* constants used in Intents
*/
public static final String ACCOUNT_ID = "account_id";
public static final String TYPE = APPLICATION_ID + ".notification.type";
private static final String TAG = "NotificationHelper";
public static final String REPLY_ACTION = "REPLY_ACTION";
@ -156,7 +149,7 @@ public class NotificationHelper {
* @return the new notification
*/
@NonNull
public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) {
public static android.app.Notification make(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account, boolean isOnlyOneInGroup) {
body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
String mastodonNotificationId = body.getId();
int accountId = (int) account.getId();
@ -208,8 +201,7 @@ public class NotificationHelper {
builder.setLargeIcon(accountAvatar);
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
if (body.getType() == Notification.Type.MENTION
&& android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (body.getType() == Notification.Type.MENTION) {
RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
.setLabel(context.getString(R.string.label_quick_reply))
.build();
@ -245,11 +237,11 @@ public class NotificationHelper {
Bundle extras = new Bundle();
// Add the sending account's name, so it can be used when summarising this notification
extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName());
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString());
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().name());
builder.addExtras(extras);
// Only alert for the first notification of a batch to avoid multiple alerts at once
if(!isFirstOfBatch) {
if(!isOnlyOneInGroup) {
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
}
@ -278,14 +270,14 @@ public class NotificationHelper {
* @param notificationManager the system's NotificationManager
* @param account the account for which the notification should be shown
*/
public static void updateSummaryNotifications(Context context, NotificationManager notificationManager, AccountEntity account) {
public static void updateSummaryNotifications(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull AccountEntity account) {
// Map from the channel ID to a list of notifications in that channel. Those are the
// notifications that will be summarised.
Map<String, List<StatusBarNotification>> channelGroups = new HashMap<>();
int accountId = (int) account.getId();
// Initialise the map with all channel IDs.
for (Notification.Type ty : Notification.Type.values()) {
for (Notification.Type ty : Notification.Type.getEntries()) {
channelGroups.put(getChannelId(account, ty), new ArrayList<>());
}
@ -325,11 +317,11 @@ public class NotificationHelper {
// Create a notification that summarises the other notifications in this group
// All notifications in this group have the same type, so get it from the first.
String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE);
String typeName = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE, Notification.Type.UNKNOWN.name());
Notification.Type notificationType = Notification.Type.valueOf(typeName);
Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, notificationType);
Intent summaryResultIntent = new Intent(context, MainActivity.class);
summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId);
summaryResultIntent.putExtra(TYPE, notificationType);
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
summaryStackBuilder.addParentStack(MainActivity.class);
summaryStackBuilder.addNextIntent(summaryResultIntent);
@ -373,10 +365,8 @@ public class NotificationHelper {
private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
// we have to switch account here
Intent eventResultIntent = new Intent(context, MainActivity.class);
eventResultIntent.putExtra(ACCOUNT_ID, account.getId());
eventResultIntent.putExtra(TYPE, body.getType().name());
Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType());
TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
eventStackBuilder.addParentStack(MainActivity.class);
eventStackBuilder.addNextIntent(eventResultIntent);
@ -464,12 +454,7 @@ public class NotificationHelper {
composeOptions.setLanguage(actionableStatus.getLanguage());
composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
Intent composeIntent = ComposeActivity.startIntent(
context,
composeOptions,
notificationId,
account.getId()
);
Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId());
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
@ -624,7 +609,7 @@ public class NotificationHelper {
}
public static void enablePullNotifications(Context context) {
public static void enablePullNotifications(@NonNull Context context) {
WorkManager workManager = WorkManager.getInstance(context);
workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
@ -652,7 +637,7 @@ public class NotificationHelper {
Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval");
}
public static void disablePullNotifications(Context context) {
public static void disablePullNotifications(@NonNull Context context) {
WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
Log.d(TAG, "disabled notification checks");
}
@ -668,7 +653,7 @@ public class NotificationHelper {
}
}
public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) {
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification notification) {
return filterNotification(notificationManager, account, notification.getType());
}
@ -874,7 +859,7 @@ public class NotificationHelper {
if (mutable) {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
} else {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
return PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
}
}
}

View File

@ -1,697 +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.components.notifications
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.LoadState
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class NotificationsFragment :
SFragment(),
StatusActionListener,
NotificationActionListener,
AccountActionListener,
OnRefreshListener,
MenuProvider,
Injectable,
ReselectableFragment {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: NotificationsViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
private lateinit var adapter: NotificationsPagingAdapter
private lateinit var layoutManager: LinearLayoutManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = NotificationsPagingAdapter(
notificationDiffCallback,
accountId = viewModel.account.accountId,
statusActionListener = this,
notificationActionListener = this,
accountActionListener = this,
statusDisplayOptions = viewModel.statusDisplayOptions.value
)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_timeline_notifications, container, false)
}
private fun updateFilterVisibility(showFilter: Boolean) {
val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams
if (showFilter) {
binding.appBarOptions.setExpanded(true, false)
binding.appBarOptions.visibility = View.VISIBLE
// Set content behaviour to hide filter on scroll
params.behavior = ScrollingViewBehavior()
} else {
binding.appBarOptions.setExpanded(false, false)
binding.appBarOptions.visibility = View.GONE
// Clear behaviour to hide app bar
params.behavior = null
}
}
private fun confirmClearNotifications() {
AlertDialog.Builder(requireContext())
.setMessage(R.string.notification_clear_text)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
// Setup the SwipeRefreshLayout.
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
// Setup the RecyclerView.
binding.recyclerView.setHasFixedSize(true)
layoutManager = LinearLayoutManager(context)
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(
binding.recyclerView,
this
) { pos: Int ->
val notification = adapter.snapshot().getOrNull(pos)
// We support replies only for now
if (notification is NotificationViewData) {
notification.statusViewData
} else {
null
}
}
)
binding.recyclerView.addItemDecoration(
DividerItemDecoration(
context,
DividerItemDecoration.VERTICAL
)
)
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
val actionButton = (activity as ActionButtonActivity).actionButton
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
actionButton?.let { fab ->
if (!viewModel.uiState.value.showFabWhileScrolling) {
if (dy > 0 && fab.isShown) {
fab.hide() // Hide when scrolling down
} else if (dy < 0 && !fab.isShown) {
fab.show() // Show when scrolling up
}
} else if (!fab.isShown) {
fab.show()
}
}
}
@Suppress("SyntheticAccessor")
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
newState != SCROLL_STATE_IDLE && return
// Save the ID of the first notification visible in the list, so the user's
// reading position is always restorable.
layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position ->
adapter.snapshot().getOrNull(position)?.id?.let { id ->
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
}
}
}
})
binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
header = NotificationsLoadStateAdapter { adapter.retry() },
footer = NotificationsLoadStateAdapter { adapter.retry() }
)
binding.buttonClear.setOnClickListener { confirmClearNotifications() }
binding.buttonFilter.setOnClickListener { showFilterDialog() }
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
false
// Signal the user that a refresh has loaded new items above their current position
// by scrolling up slightly to disclose the new content
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0 && adapter.itemCount != itemCount) {
binding.recyclerView.post {
if (getView() != null) {
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
}
}
}
}
})
// update post timestamps
val updateTimestampFlow = flow {
while (true) {
delay(60000)
emit(Unit)
}
}.onEach {
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.pagingData.collectLatest { pagingData ->
Log.d(TAG, "Submitting data to adapter")
adapter.submitData(pagingData)
}
}
// Show errors from the view model as snack bars.
//
// Errors are shown:
// - Indefinitely, so the user has a chance to read and understand
// the message
// - With a max of 5 text lines, to allow space for longer errors.
// E.g., on a typical device, an error message like "Bookmarking
// post failed: Unable to resolve host 'mastodon.social': No
// address associated with hostname" is 3 lines.
// - With a "Retry" option if the error included a UiAction to retry.
launch {
viewModel.uiError.collect { error ->
Log.d(TAG, error.toString())
val message = getString(
error.message,
error.throwable.localizedMessage
?: getString(R.string.ui_error_unknown)
)
val snackbar = Snackbar.make(
// Without this the FAB will not move out of the way
(activity as ActionButtonActivity).actionButton ?: binding.root,
message,
Snackbar.LENGTH_INDEFINITE
).setTextMaxLines(5)
error.action?.let { action ->
snackbar.setAction(R.string.action_retry) {
viewModel.accept(action)
}
}
snackbar.show()
// The status view has pre-emptively updated its state to show
// that the action succeeded. Since it hasn't, re-bind the view
// to show the correct data.
error.action?.let { action ->
action is StatusAction || return@let
val position = adapter.snapshot().indexOfFirst {
it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id
}
if (position != RecyclerView.NO_POSITION) {
adapter.notifyItemChanged(position)
}
}
}
}
// Show successful notification action as brief snackbars, so the
// user is clear the action has happened.
launch {
viewModel.uiSuccess
.filterIsInstance<NotificationActionSuccess>()
.collect {
Snackbar.make(
(activity as ActionButtonActivity).actionButton ?: binding.root,
getString(it.msg),
Snackbar.LENGTH_SHORT
).show()
when (it) {
// The follow request is no longer valid, refresh the adapter to
// remove it.
is NotificationActionSuccess.AcceptFollowRequest,
is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh()
}
}
}
// Update adapter data when status actions are successful, and re-bind to update
// the UI.
launch {
viewModel.uiSuccess
.filterIsInstance<StatusActionSuccess>()
.collect {
val indexedViewData = adapter.snapshot()
.withIndex()
.firstOrNull { notificationViewData ->
notificationViewData.value?.statusViewData?.status?.id ==
it.action.statusViewData.id
} ?: return@collect
val statusViewData =
indexedViewData.value?.statusViewData ?: return@collect
val status = when (it) {
is StatusActionSuccess.Bookmark ->
statusViewData.status.copy(bookmarked = it.action.state)
is StatusActionSuccess.Favourite ->
statusViewData.status.copy(favourited = it.action.state)
is StatusActionSuccess.Reblog ->
statusViewData.status.copy(reblogged = it.action.state)
is StatusActionSuccess.VoteInPoll ->
statusViewData.status.copy(
poll = it.action.poll.votedCopy(it.action.choices)
)
}
indexedViewData.value?.statusViewData = statusViewData.copy(
status = status
)
adapter.notifyItemChanged(indexedViewData.index)
}
}
// Refresh adapter on mutes and blocks
launch {
viewModel.uiSuccess.collectLatest {
when (it) {
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation ->
adapter.refresh()
else -> { /* nothing to do */
}
}
}
}
// Update filter option visibility from uiState
launch {
viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) }
}
// Update status display from statusDisplayOptions. If the new options request
// relative time display collect the flow to periodically update the timestamp in the list gui elements.
launch {
viewModel.statusDisplayOptions
.collectLatest {
// NOTE this this also triggered (emitted?) on resume.
adapter.statusDisplayOptions = it
adapter.notifyItemRangeChanged(0, adapter.itemCount, null)
if (!it.useAbsoluteTime) {
updateTimestampFlow.collect()
}
}
}
// Update the UI from the loadState
adapter.loadStateFlow
.distinctUntilChangedBy { it.refresh }
.collect { loadState ->
binding.recyclerView.isVisible = true
binding.progressBar.isVisible = loadState.refresh is LoadState.Loading &&
!binding.swipeRefreshLayout.isRefreshing
binding.swipeRefreshLayout.isRefreshing =
loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible
binding.statusView.isVisible = false
if (loadState.refresh is LoadState.NotLoading) {
if (adapter.itemCount == 0) {
binding.statusView.setup(
R.drawable.elephant_friend_empty,
R.string.message_empty
)
binding.recyclerView.isVisible = false
binding.statusView.isVisible = true
} else {
binding.statusView.isVisible = false
}
}
if (loadState.refresh is LoadState.Error) {
when ((loadState.refresh as LoadState.Error).error) {
is IOException -> {
binding.statusView.setup(
R.drawable.elephant_offline,
R.string.error_network
) { adapter.retry() }
}
else -> {
binding.statusView.setup(
R.drawable.elephant_error,
R.string.error_generic
) { adapter.retry() }
}
}
binding.recyclerView.isVisible = false
binding.statusView.isVisible = true
}
}
}
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_notifications, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
onRefresh()
true
}
R.id.load_newest -> {
viewModel.accept(InfallibleUiAction.LoadNewest)
true
}
else -> false
}
}
override fun onRefresh() {
binding.progressBar.isVisible = false
adapter.refresh()
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
}
override fun onPause() {
super.onPause()
// Save the ID of the first notification visible in the list
val position = layoutManager.findFirstVisibleItemPosition()
if (position >= 0) {
adapter.snapshot().getOrNull(position)?.id?.let { id ->
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
}
}
}
override fun onResume() {
super.onResume()
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
}
override fun onReply(position: Int) {
val status = adapter.peek(position)?.statusViewData?.status ?: return
super.reply(status)
}
override fun onReblog(reblog: Boolean, position: Int) {
val statusViewData = adapter.peek(position)?.statusViewData ?: return
viewModel.accept(StatusAction.Reblog(reblog, statusViewData))
}
override fun onFavourite(favourite: Boolean, position: Int) {
val statusViewData = adapter.peek(position)?.statusViewData ?: return
viewModel.accept(StatusAction.Favourite(favourite, statusViewData))
}
override fun onQuote(position: Int) {
val status = adapter.peek(position)?.statusViewData?.status ?: return
super.quote(status)
}
override fun onBookmark(bookmark: Boolean, position: Int) {
val statusViewData = adapter.peek(position)?.statusViewData ?: return
viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData))
}
override fun onVoteInPoll(position: Int, choices: List<Int>) {
val statusViewData = adapter.peek(position)?.statusViewData ?: return
val poll = statusViewData.status.poll ?: return
viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData))
}
override fun onMore(view: View, position: Int) {
val status = adapter.peek(position)?.statusViewData?.status ?: return
super.more(status, view, position)
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = adapter.peek(position)?.statusViewData?.status ?: return
super.viewMedia(attachmentIndex, list(status), view)
}
override fun onViewThread(position: Int) {
val status = adapter.peek(position)?.statusViewData?.status ?: return
super.viewThread(status.actionableId, status.actionableStatus.url)
}
override fun onOpenReblog(position: Int) {
val account = adapter.peek(position)?.account!!
onViewAccount(account.id)
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
val notificationViewData = adapter.snapshot()[position] ?: return
notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
isExpanded = expanded
)
adapter.notifyItemChanged(position)
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
val notificationViewData = adapter.snapshot()[position] ?: return
notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
isShowingContent = isShowing
)
adapter.notifyItemChanged(position)
}
override fun onLoadMore(position: Int) {
// Empty -- this fragment doesn't show placeholders
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
val notificationViewData = adapter.snapshot()[position] ?: return
notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
isCollapsed = isCollapsed
)
adapter.notifyItemChanged(position)
}
override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) {
onContentCollapsedChange(isCollapsed, position)
}
override fun clearWarningAction(position: Int) {
}
private fun clearNotifications() {
binding.swipeRefreshLayout.isRefreshing = false
binding.progressBar.isVisible = false
viewModel.accept(FallibleUiAction.ClearNotifications)
}
private fun showFilterDialog() {
FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter ->
if (viewModel.uiState.value.activeFilter != filter) {
viewModel.accept(InfallibleUiAction.ApplyFilter(filter))
}
}
.show(parentFragmentManager, "dialogFilter")
}
override fun onViewTag(tag: String) {
super.viewTag(tag)
}
override fun onViewAccount(id: String) {
super.viewAccount(id)
}
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
adapter.refresh()
}
override fun onBlock(block: Boolean, id: String, position: Int) {
adapter.refresh()
}
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
if (accept) {
viewModel.accept(NotificationAction.AcceptFollowRequest(accountId))
} else {
viewModel.accept(NotificationAction.RejectFollowRequest(accountId))
}
}
override fun onViewThreadForStatus(status: Status) {
super.viewThread(status.actionableId, status.actionableStatus.url)
}
override fun onViewReport(reportId: String) {
requireContext().openLink(
"https://${viewModel.account.domain}/admin/reports/$reportId"
)
}
public override fun removeItem(position: Int) {
// Empty -- this fragment doesn't remove items
}
override fun onReselect() {
if (isAdded) {
binding.appBarOptions.setExpanded(true, false)
layoutManager.scrollToPosition(0)
}
}
companion object {
private const val TAG = "NotificationsFragment"
fun newInstance() = NotificationsFragment()
private val notificationDiffCallback: DiffUtil.ItemCallback<NotificationViewData> =
object : DiffUtil.ItemCallback<NotificationViewData>() {
override fun areItemsTheSame(
oldItem: NotificationViewData,
newItem: NotificationViewData
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: NotificationViewData,
newItem: NotificationViewData
): Boolean {
return false
}
override fun getChangePayload(
oldItem: NotificationViewData,
newItem: NotificationViewData
): Any? {
return if (oldItem == newItem) {
// If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
} else {
// If items are different - update a whole view holder
null
}
}
}
}
}
class FilterDialogFragment(
private val activeFilter: Set<Notification.Type>,
private val listener: ((filter: Set<Notification.Type>) -> Unit)
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray()
val checkedItems = Notification.Type.visibleTypes.map {
!activeFilter.contains(it)
}.toBooleanArray()
val builder = AlertDialog.Builder(context)
.setTitle(R.string.notifications_apply_filter)
.setMultiChoiceItems(items, checkedItems) { _, which, isChecked ->
checkedItems[which] = isChecked
}
.setPositiveButton(android.R.string.ok) { _, _ ->
val excludes: MutableSet<Notification.Type> = HashSet()
for (i in Notification.Type.visibleTypes.indices) {
if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i])
}
listener(excludes)
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
return builder.create()
}
}

View File

@ -1,73 +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.components.notifications
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.paging.LoadState
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding
import java.net.SocketTimeoutException
/**
* Display the header/footer loading state to the user.
*
* Either:
*
* 1. A page is being loaded, display a progress view, or
* 2. An error occurred, display an error message with a "retry" button
*
* @param retry function to invoke if the user clicks the "retry" button
*/
class NotificationsLoadStateViewHolder(
private val binding: ItemNotificationsLoadStateFooterViewBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.retryButton.setOnClickListener { retry.invoke() }
}
fun bind(loadState: LoadState) {
if (loadState is LoadState.Error) {
val ctx = binding.root.context
binding.errorMsg.text = when (loadState.error) {
is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception)
// Other exceptions to consider:
// - UnknownHostException, default text is:
// Unable to resolve "%s": No address associated with hostname
else -> loadState.error.localizedMessage
}
}
binding.progressBar.isVisible = loadState is LoadState.Loading
binding.retryButton.isVisible = loadState is LoadState.Error
binding.errorMsg.isVisible = loadState is LoadState.Error
}
companion object {
fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder {
val binding = ItemNotificationsLoadStateFooterViewBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return NotificationsLoadStateViewHolder(binding, retry)
}
}
}

View File

@ -1,207 +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.components.notifications
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder
import com.keylesspalace.tusky.databinding.ItemFollowBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
import com.keylesspalace.tusky.databinding.ItemStatusBinding
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
import com.keylesspalace.tusky.databinding.SimpleListItem1Binding
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
/** How to present the notification in the UI */
enum class NotificationViewKind {
/** View as the original status */
STATUS,
/** View as the original status, with the interaction type above */
NOTIFICATION,
FOLLOW,
FOLLOW_REQUEST,
REPORT,
UNKNOWN;
companion object {
fun from(kind: Notification.Type?): NotificationViewKind {
return when (kind) {
Notification.Type.MENTION,
Notification.Type.POLL,
Notification.Type.UNKNOWN -> STATUS
Notification.Type.FAVOURITE,
Notification.Type.REBLOG,
Notification.Type.STATUS,
Notification.Type.UPDATE -> NOTIFICATION
Notification.Type.FOLLOW,
Notification.Type.SIGN_UP -> FOLLOW
Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST
Notification.Type.REPORT -> REPORT
null -> UNKNOWN
}
}
}
}
interface NotificationActionListener {
fun onViewAccount(id: String)
fun onViewThreadForStatus(status: Status)
fun onViewReport(reportId: String)
/**
* Called when the status has a content warning and the visibility of the content behind
* the warning is being changed.
*
* @param expanded the desired state of the content behind the content warning
* @param position the adapter position of the view
*
*/
fun onExpandedChange(expanded: Boolean, position: Int)
/**
* Called when the status [android.widget.ToggleButton] responsible for collapsing long
* status content is interacted with.
*
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
* @param position The position of the status in the list.
*/
fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int)
}
class NotificationsPagingAdapter(
diffCallback: DiffUtil.ItemCallback<NotificationViewData>,
/** ID of the the account that notifications are being displayed for */
private val accountId: String,
private val statusActionListener: StatusActionListener,
private val notificationActionListener: NotificationActionListener,
private val accountActionListener: AccountActionListener,
var statusDisplayOptions: StatusDisplayOptions
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(diffCallback) {
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
/** View holders in this adapter must implement this interface */
interface ViewHolder {
/** Bind the data from the notification and payloads to the view */
fun bind(
viewData: NotificationViewData,
payloads: List<*>?,
statusDisplayOptions: StatusDisplayOptions
)
}
override fun getItemViewType(position: Int): Int {
return NotificationViewKind.from(getItem(position)?.type).ordinal
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (NotificationViewKind.values()[viewType]) {
NotificationViewKind.STATUS -> {
StatusViewHolder(
ItemStatusBinding.inflate(inflater, parent, false),
statusActionListener,
accountId
)
}
NotificationViewKind.NOTIFICATION -> {
StatusNotificationViewHolder(
ItemStatusNotificationBinding.inflate(inflater, parent, false),
statusActionListener,
notificationActionListener,
absoluteTimeFormatter
)
}
NotificationViewKind.FOLLOW -> {
FollowViewHolder(
ItemFollowBinding.inflate(inflater, parent, false),
notificationActionListener,
statusActionListener
)
}
NotificationViewKind.FOLLOW_REQUEST -> {
FollowRequestViewHolder(
ItemFollowRequestBinding.inflate(inflater, parent, false),
accountActionListener,
statusActionListener,
showHeader = true
)
}
NotificationViewKind.REPORT -> {
ReportNotificationViewHolder(
ItemReportNotificationBinding.inflate(inflater, parent, false),
notificationActionListener
)
}
else -> {
FallbackNotificationViewHolder(
SimpleListItem1Binding.inflate(inflater, parent, false)
)
}
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
bindViewHolder(holder, position, null)
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
bindViewHolder(holder, position, payloads)
}
private fun bindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>?
) {
getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) }
}
/**
* Notification view holder to use if no other type is appropriate. Should never normally
* be used, but is useful when migrating code.
*/
private class FallbackNotificationViewHolder(
val binding: SimpleListItem1Binding
) : ViewHolder, RecyclerView.ViewHolder(binding.root) {
override fun bind(
viewData: NotificationViewData,
payloads: List<*>?,
statusDisplayOptions: StatusDisplayOptions
) {
binding.text1.text = viewData.statusViewData?.content
}
}
}

View File

@ -1,216 +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.components.notifications
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.google.gson.Gson
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import okhttp3.Headers
import retrofit2.Response
import javax.inject.Inject
/** Models next/prev links from the "Links" header in an API response */
data class Links(val next: String?, val prev: String?) {
companion object {
fun from(linkHeader: String?): Links {
val links = HttpHeaderLink.parse(linkHeader)
return Links(
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
"max_id"
),
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
"min_id"
)
)
}
}
}
/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
class NotificationsPagingSource @Inject constructor(
private val mastodonApi: MastodonApi,
private val gson: Gson,
private val notificationFilter: Set<Notification.Type>
) : PagingSource<String, Notification>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}")
try {
val response = when (params) {
is LoadParams.Refresh -> {
getInitialPage(params)
}
is LoadParams.Append -> mastodonApi.notifications(
maxId = params.key,
limit = params.loadSize,
excludes = notificationFilter
)
is LoadParams.Prepend -> mastodonApi.notifications(
minId = params.key,
limit = params.loadSize,
excludes = notificationFilter
)
}
if (!response.isSuccessful) {
val code = response.code()
val msg = response.errorBody()?.string()?.let { errorBody ->
if (errorBody.isBlank()) return@let "no reason given"
val error = try {
gson.fromJson(errorBody, com.keylesspalace.tusky.entity.Error::class.java)
} catch (e: Exception) {
return@let "$errorBody ($e)"
}
when (val desc = error.error_description) {
null -> error.error
else -> "${error.error}: $desc"
}
} ?: "no reason given"
return LoadResult.Error(Throwable("HTTP $code: $msg"))
}
val links = Links.from(response.headers()["link"])
return LoadResult.Page(
data = response.body()!!,
nextKey = links.next,
prevKey = links.prev
)
} catch (e: Exception) {
return LoadResult.Error(e)
}
}
/**
* Fetch the initial page of notifications, using params.key as the ID of the initial
* notification to fetch.
*
* - If there is no key, a page of the most recent notifications is returned
* - If the notification exists, and is not filtered, a page of notifications is returned
* - If the notification does not exist, or is filtered, the page of notifications immediately
* before is returned (if non-empty)
* - If there is no page of notifications immediately before then the page immediately after
* is returned (if non-empty)
* - Finally, fall back to the most recent notifications
*/
private suspend fun getInitialPage(params: LoadParams<String>): Response<List<Notification>> = coroutineScope {
// If the key is null this is straightforward, just return the most recent notifications.
val key = params.key
?: return@coroutineScope mastodonApi.notifications(
limit = params.loadSize,
excludes = notificationFilter
)
// It's important to return *something* from this state. If an empty page is returned
// (even with next/prev links) Pager3 assumes there is no more data to load and stops.
//
// In addition, the Mastodon API does not let you fetch a page that contains a given key.
// You can fetch the page immediately before the key, or the page immediately after, but
// you can not fetch the page itself.
// First, try and get the notification itself, and the notifications immediately before
// it. This is so that a full page of results can be returned. Returning just the
// single notification means the displayed list can jump around a bit as more data is
// loaded.
//
// Make both requests, and wait for the first to complete.
val deferredNotification = async { mastodonApi.notification(id = key) }
val deferredNotificationPage = async {
mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter)
}
val notification = deferredNotification.await()
if (notification.isSuccessful) {
// If this was successful we must still check that the user is not filtering this type
// of notification, as fetching a single notification ignores filters. Returning this
// notification if the user is filtering the type is wrong.
notification.body()?.let { body ->
if (!notificationFilter.contains(body.type)) {
// Notification is *not* filtered. We can return this, but need the next page of
// notifications as well
// Collect all notifications in to this list
val notifications = mutableListOf(body)
val notificationPage = deferredNotificationPage.await()
if (notificationPage.isSuccessful) {
notificationPage.body()?.let {
notifications.addAll(it)
}
}
// "notifications" now contains at least one notification we can return, and
// hopefully a full page.
// Build correct max_id and min_id links for the response. The "min_id" to use
// when fetching the next page is the same as "key". The "max_id" is the ID of
// the oldest notification in the list.
val maxId = notifications.last().id
val headers = Headers.Builder()
.add("link: </?max_id=$maxId>; rel=\"next\", </?min_id=$key>; rel=\"prev\"")
.build()
return@coroutineScope Response.success(notifications, headers)
}
}
}
// The user's last read notification was missing or is filtered. Use the page of
// notifications chronologically older than their desired notification. This page must
// *not* be empty (as noted earlier, if it is, paging stops).
deferredNotificationPage.await().let { response ->
if (response.isSuccessful) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response
}
}
// There were no notifications older than the user's desired notification. Return the page
// of notifications immediately newer than their desired notification. This page must
// *not* be empty (as noted earlier, if it is, paging stops).
mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response ->
if (response.isSuccessful) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response
}
}
// Everything failed -- fallback to fetching the most recent notifications
return@coroutineScope mastodonApi.notifications(
limit = params.loadSize,
excludes = notificationFilter
)
}
override fun getRefreshKey(state: PagingState<String, Notification>): String? {
return state.anchorPosition?.let { anchorPosition ->
val id = state.closestItemToPosition(anchorPosition)?.id
Log.d(TAG, " getRefreshKey returning $id")
return id
}
}
companion object {
private const val TAG = "NotificationsPagingSource"
}
}

View File

@ -1,76 +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.components.notifications
import android.util.Log
import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingSource
import com.google.gson.Gson
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.flow.Flow
import okhttp3.ResponseBody
import retrofit2.Response
import javax.inject.Inject
class NotificationsRepository @Inject constructor(
private val mastodonApi: MastodonApi,
private val gson: Gson
) {
private var factory: InvalidatingPagingSourceFactory<String, Notification>? = null
/**
* @return flow of Mastodon [Notification], excluding all types in [filter].
* Notifications are loaded in [pageSize] increments.
*/
fun getNotificationsStream(
filter: Set<Notification.Type>,
pageSize: Int = PAGE_SIZE,
initialKey: String? = null
): Flow<PagingData<Notification>> {
Log.d(TAG, "getNotificationsStream(), filtering: $filter")
factory = InvalidatingPagingSourceFactory {
NotificationsPagingSource(mastodonApi, gson, filter)
}
return Pager(
config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize),
initialKey = initialKey,
pagingSourceFactory = factory!!
).flow
}
/** Invalidate the active paging source, see [PagingSource.invalidate] */
fun invalidate() {
factory?.invalidate()
}
/** Clear notifications */
suspend fun clearNotifications(): Response<ResponseBody> {
return mastodonApi.clearNotifications()
}
companion object {
private const val TAG = "NotificationsRepository"
private const val PAGE_SIZE = 30
}
}

View File

@ -1,557 +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.components.notifications
import android.content.SharedPreferences
import android.util.Log
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteConversationEvent
import com.keylesspalace.tusky.appstore.MuteEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.serialize
import com.keylesspalace.tusky.util.throttleFirst
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
data class UiState(
/** Filtered notification types */
val activeFilter: Set<Notification.Type> = emptySet(),
/** True if the UI to filter and clear notifications should be shown */
val showFilterOptions: Boolean = false,
/** True if the FAB should be shown while scrolling */
val showFabWhileScrolling: Boolean = true
)
/** Preferences the UI reacts to */
data class UiPrefs(
val showFabWhileScrolling: Boolean,
val showFilter: Boolean
) {
companion object {
/** Relevant preference keys. Changes to any of these trigger a display update */
val prefKeys = setOf(
PrefKeys.FAB_HIDE,
PrefKeys.SHOW_NOTIFICATIONS_FILTER
)
}
}
/** Parent class for all UI actions, fallible or infallible. */
sealed class UiAction
/** Actions the user can trigger from the UI. These actions may fail. */
sealed class FallibleUiAction : UiAction() {
/** Clear all notifications */
object ClearNotifications : FallibleUiAction()
}
/**
* Actions the user can trigger from the UI that either cannot fail, or if they do fail,
* do not show an error.
*/
sealed class InfallibleUiAction : UiAction() {
/** Apply a new filter to the notification list */
// This saves the list to the local database, which triggers a refresh of the data.
// Saving the data can't fail, which is why this is infallible. Refreshing the
// data may fail, but that's handled by the paging system / adapter refresh logic.
data class ApplyFilter(val filter: Set<Notification.Type>) : InfallibleUiAction()
/**
* User is leaving the fragment, save the ID of the visible notification.
*
* Infallible because if it fails there's nowhere to show the error, and nothing the user
* can do.
*/
data class SaveVisibleId(val visibleId: String) : InfallibleUiAction()
/** Ignore the saved reading position, load the page with the newest items */
// Resets the account's `lastNotificationId`, which can't fail, which is why this is
// infallible. Reloading the data may fail, but that's handled by the paging system /
// adapter refresh logic.
object LoadNewest : InfallibleUiAction()
}
/** Actions the user can trigger on an individual notification. These may fail. */
sealed class NotificationAction : FallibleUiAction() {
data class AcceptFollowRequest(val accountId: String) : NotificationAction()
data class RejectFollowRequest(val accountId: String) : NotificationAction()
}
sealed class UiSuccess {
// These three are from menu items on the status. Currently they don't come to the
// viewModel as actions, they're noticed when events are posted. That will change,
// but for the moment we can still report them to the UI. Typically, receiving any
// of these three should trigger the UI to refresh.
/** A user was blocked */
object Block : UiSuccess()
/** A user was muted */
object Mute : UiSuccess()
/** A conversation was muted */
object MuteConversation : UiSuccess()
}
/** The result of a successful action on a notification */
sealed class NotificationActionSuccess(
/** String resource with an error message to show the user */
@StringRes val msg: Int,
/**
* The original action, in case additional information is required from it to display the
* message.
*/
open val action: NotificationAction
) : UiSuccess() {
data class AcceptFollowRequest(override val action: NotificationAction) :
NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action)
data class RejectFollowRequest(override val action: NotificationAction) :
NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action)
companion object {
fun from(action: NotificationAction) = when (action) {
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action)
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action)
}
}
}
/** Actions the user can trigger on an individual status */
sealed class StatusAction(
open val statusViewData: StatusViewData.Concrete
) : FallibleUiAction() {
/** Set the bookmark state for a status */
data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
StatusAction(statusViewData)
/** Set the favourite state for a status */
data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
StatusAction(statusViewData)
/** Set the reblog state for a status */
data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
StatusAction(statusViewData)
/** Vote in a poll */
data class VoteInPoll(
val poll: Poll,
val choices: List<Int>,
override val statusViewData: StatusViewData.Concrete
) : StatusAction(statusViewData)
}
/** Changes to a status' visible state after API calls */
sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
data class Bookmark(override val action: StatusAction.Bookmark) :
StatusActionSuccess(action)
data class Favourite(override val action: StatusAction.Favourite) :
StatusActionSuccess(action)
data class Reblog(override val action: StatusAction.Reblog) :
StatusActionSuccess(action)
data class VoteInPoll(override val action: StatusAction.VoteInPoll) :
StatusActionSuccess(action)
companion object {
fun from(action: StatusAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(action)
is StatusAction.Favourite -> Favourite(action)
is StatusAction.Reblog -> Reblog(action)
is StatusAction.VoteInPoll -> VoteInPoll(action)
}
}
}
/** Errors from fallible view model actions that the UI will need to show */
sealed class UiError(
/** The exception associated with the error */
open val throwable: Throwable,
/** String resource with an error message to show the user */
@StringRes val message: Int,
/** The action that failed. Can be resent to retry the action */
open val action: UiAction? = null
) {
data class ClearNotifications(override val throwable: Throwable) : UiError(
throwable,
R.string.ui_error_clear_notifications
)
data class Bookmark(
override val throwable: Throwable,
override val action: StatusAction.Bookmark
) : UiError(throwable, R.string.ui_error_bookmark, action)
data class Favourite(
override val throwable: Throwable,
override val action: StatusAction.Favourite
) : UiError(throwable, R.string.ui_error_favourite, action)
data class Reblog(
override val throwable: Throwable,
override val action: StatusAction.Reblog
) : UiError(throwable, R.string.ui_error_reblog, action)
data class VoteInPoll(
override val throwable: Throwable,
override val action: StatusAction.VoteInPoll
) : UiError(throwable, R.string.ui_error_vote, action)
data class AcceptFollowRequest(
override val throwable: Throwable,
override val action: NotificationAction.AcceptFollowRequest
) : UiError(throwable, R.string.ui_error_accept_follow_request, action)
data class RejectFollowRequest(
override val throwable: Throwable,
override val action: NotificationAction.RejectFollowRequest
) : UiError(throwable, R.string.ui_error_reject_follow_request, action)
companion object {
fun make(throwable: Throwable, action: FallibleUiAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(throwable, action)
is StatusAction.Favourite -> Favourite(throwable, action)
is StatusAction.Reblog -> Reblog(throwable, action)
is StatusAction.VoteInPoll -> VoteInPoll(throwable, action)
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action)
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action)
FallibleUiAction.ClearNotifications -> ClearNotifications(throwable)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class)
class NotificationsViewModel @Inject constructor(
private val repository: NotificationsRepository,
private val preferences: SharedPreferences,
private val accountManager: AccountManager,
private val timelineCases: TimelineCases,
private val eventHub: EventHub
) : ViewModel() {
/** The account to display notifications for */
val account = accountManager.activeAccount!!
val uiState: StateFlow<UiState>
/** Flow of changes to statusDisplayOptions, for use by the UI */
val statusDisplayOptions: StateFlow<StatusDisplayOptions>
val pagingData: Flow<PagingData<NotificationViewData>>
/** 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: 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>()
/** 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 ->
viewModelScope.launch { uiAction.emit(action) }
}
init {
// Handle changes to notification filters
val notificationFilter = uiAction
.filterIsInstance<InfallibleUiAction.ApplyFilter>()
.distinctUntilChanged()
// Save each change back to the active account
.onEach { action ->
Log.d(TAG, "notificationFilter: $action")
account.notificationsFilter = serialize(action.filter)
accountManager.saveAccount(account)
}
// Load the initial filter from the active account
.onStart {
emit(
InfallibleUiAction.ApplyFilter(
filter = deserialize(account.notificationsFilter)
)
)
}
// Reset the last notification ID to "0" to fetch the newest notifications, and
// increment `reload` to trigger creation of a new PagingSource.
viewModelScope.launch {
uiAction
.filterIsInstance<InfallibleUiAction.LoadNewest>()
.collectLatest {
account.lastNotificationId = "0"
accountManager.saveAccount(account)
reload.getAndUpdate { it + 1 }
}
}
// Save the visible notification ID
viewModelScope.launch {
uiAction
.filterIsInstance<InfallibleUiAction.SaveVisibleId>()
.distinctUntilChanged()
.collectLatest { action ->
Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}")
account.lastNotificationId = action.visibleId
accountManager.saveAccount(account)
}
}
// Set initial status display options from the user's preferences.
//
// Then collect future preference changes and emit new values in to
// statusDisplayOptions if necessary.
statusDisplayOptions = MutableStateFlow(
StatusDisplayOptions.from(
preferences,
account
)
)
viewModelScope.launch {
eventHub.events
.filterIsInstance<PreferenceChangedEvent>()
.filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) }
.map {
statusDisplayOptions.value.make(
preferences,
it.preferenceKey,
account
)
}
.collect {
statusDisplayOptions.emit(it)
}
}
// Handle UiAction.ClearNotifications
viewModelScope.launch {
uiAction.filterIsInstance<FallibleUiAction.ClearNotifications>()
.collectLatest {
try {
repository.clearNotifications().apply {
if (this.isSuccessful) {
repository.invalidate()
} else {
_uiErrorChannel.send(UiError.make(HttpException(this), it))
}
}
} catch (e: Exception) {
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) }
}
}
}
// Handle NotificationAction.*
viewModelScope.launch {
uiAction.filterIsInstance<NotificationAction>()
.throttleFirst(THROTTLE_TIMEOUT)
.collect { action ->
try {
when (action) {
is NotificationAction.AcceptFollowRequest ->
timelineCases.acceptFollowRequest(action.accountId).await()
is NotificationAction.RejectFollowRequest ->
timelineCases.rejectFollowRequest(action.accountId).await()
}
uiSuccess.emit(NotificationActionSuccess.from(action))
} catch (e: Exception) {
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) }
}
}
}
// Handle StatusAction.*
viewModelScope.launch {
uiAction.filterIsInstance<StatusAction>()
.throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps
.collect { action ->
try {
when (action) {
is StatusAction.Bookmark ->
timelineCases.bookmark(
action.statusViewData.actionableId,
action.state
)
is StatusAction.Favourite ->
timelineCases.favourite(
action.statusViewData.actionableId,
action.state
)
is StatusAction.Reblog ->
timelineCases.reblog(
action.statusViewData.actionableId,
action.state
)
is StatusAction.VoteInPoll ->
timelineCases.voteInPoll(
action.statusViewData.actionableId,
action.poll.id,
action.choices
)
}.getOrThrow()
uiSuccess.emit(StatusActionSuccess.from(action))
} catch (t: Throwable) {
_uiErrorChannel.send(UiError.make(t, action))
}
}
}
// Handle events that should refresh the list
viewModelScope.launch {
eventHub.events.collectLatest {
when (it) {
is BlockEvent -> uiSuccess.emit(UiSuccess.Block)
is MuteEvent -> uiSuccess.emit(UiSuccess.Mute)
is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation)
}
}
}
// Re-fetch notifications if either of `notificationFilter` or `reload` flows have
// new items.
pagingData = combine(notificationFilter, reload) { action, _ -> action }
.flatMapLatest { action ->
getNotifications(filters = action.filter, initialKey = getInitialKey())
}.cachedIn(viewModelScope)
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
UiState(
activeFilter = filter.filter,
showFilterOptions = prefs.showFilter,
showFabWhileScrolling = prefs.showFabWhileScrolling
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = UiState()
)
}
private fun getNotifications(
filters: Set<Notification.Type>,
initialKey: String? = null
): Flow<PagingData<NotificationViewData>> {
return repository.getNotificationsStream(filter = filters, initialKey = initialKey)
.map { pagingData ->
pagingData.map { notification ->
notification.toViewData(
isShowingContent = statusDisplayOptions.value.showSensitiveMedia ||
!(notification.status?.actionableStatus?.sensitive ?: false),
isExpanded = statusDisplayOptions.value.openSpoiler,
isCollapsed = true
)
}
}
}
// The database stores "0" as the last notification ID if notifications have not been
// fetched. Convert to null to ensure a full fetch in this case
private fun getInitialKey(): String? {
val initialKey = when (val id = account.lastNotificationId) {
"0" -> null
else -> id
}
Log.d(TAG, "Restoring at $initialKey")
return initialKey
}
/**
* @return Flow of relevant preferences that change the UI
*/
// TODO: Preferences should be in a repository
private fun getUiPrefs() = eventHub.events
.filterIsInstance<PreferenceChangedEvent>()
.filter { UiPrefs.prefKeys.contains(it.preferenceKey) }
.map { toPrefs() }
.onStart { emit(toPrefs()) }
private fun toPrefs() = UiPrefs(
showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false),
showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
)
companion object {
private const val TAG = "NotificationsViewModel"
private val THROTTLE_TIMEOUT = 500.milliseconds
}
}

View File

@ -1,404 +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.components.notifications
import android.content.Context
import android.graphics.PorterDuff
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.text.InputFilter
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextUtils
import android.text.format.DateUtils
import android.text.style.StyleSpan
import android.view.View
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
import com.keylesspalace.tusky.util.SmartLengthInputFilter
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import net.accelf.yuito.QuoteInlineHelper
import java.util.Date
/**
* View holder for a status with an activity to be notified about (posted, boosted,
* favourited, or edited, per [NotificationViewKind.from]).
*
* Shows a line with the activity, and who initiated the activity. Clicking this should
* go to the profile page for the initiator.
*
* Displays the original status below that. Clicking this should go to the original
* status in context.
*/
internal class StatusNotificationViewHolder(
private val binding: ItemStatusNotificationBinding,
private val statusActionListener: StatusActionListener,
private val notificationActionListener: NotificationActionListener,
private val absoluteTimeFormatter: AbsoluteTimeFormatter
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_48dp
)
private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_36dp
)
private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_24dp
)
override fun bind(
viewData: NotificationViewData,
payloads: List<*>?,
statusDisplayOptions: StatusDisplayOptions
) {
val statusViewData = viewData.statusViewData
if (payloads.isNullOrEmpty()) {
// Hide null statuses. Shouldn't happen according to the spec, but some servers
// have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
if (statusViewData == null) {
showNotificationContent(false)
} else {
showNotificationContent(true)
val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable
setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
setUsername(account.username)
setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)
if (viewData.type == Notification.Type.STATUS ||
viewData.type == Notification.Type.UPDATE
) {
setAvatar(
account.avatar,
account.bot,
statusDisplayOptions.animateAvatars,
statusDisplayOptions.showBotOverlay
)
} else {
setAvatars(
account.avatar,
viewData.account.avatar,
statusDisplayOptions.animateAvatars
)
}
binding.notificationContainer.setOnClickListener {
notificationActionListener.onViewThreadForStatus(statusViewData.status)
}
binding.notificationContent.setOnClickListener {
notificationActionListener.onViewThreadForStatus(statusViewData.status)
}
binding.notificationTopText.setOnClickListener {
notificationActionListener.onViewAccount(viewData.account.id)
}
setQuoteContainer(statusViewData.quoteViewData, statusDisplayOptions)
}
setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis)
} else {
for (item in payloads) {
if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) {
setCreatedAt(
statusViewData.status.actionableStatus.createdAt,
statusDisplayOptions.useAbsoluteTime
)
}
}
}
}
private fun showNotificationContent(show: Boolean) {
binding.statusDisplayName.visibility = if (show) View.VISIBLE else View.GONE
binding.statusUsername.visibility = if (show) View.VISIBLE else View.GONE
binding.statusMetaInfo.visibility = if (show) View.VISIBLE else View.GONE
binding.notificationContentWarningDescription.visibility =
if (show) View.VISIBLE else View.GONE
binding.notificationContentWarningButton.visibility =
if (show) View.VISIBLE else View.GONE
binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE
binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE
binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE
}
private fun setDisplayName(name: String, emojis: List<Emoji>?, animateEmojis: Boolean) {
val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis)
binding.statusDisplayName.text = emojifiedName
}
private fun setUsername(name: String) {
val context = binding.statusUsername.context
val format = context.getString(R.string.post_username_format)
val usernameText = String.format(format, name)
binding.statusUsername.text = usernameText
}
private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) {
if (useAbsoluteTime) {
binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true)
} else {
// This is the visible timestampInfo.
val readout: String
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
* as 17 meters instead of minutes. */
val readoutAloud: CharSequence
if (createdAt != null) {
val then = createdAt.time
val now = Date().time
readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now)
readoutAloud = DateUtils.getRelativeTimeSpanString(
then,
now,
DateUtils.SECOND_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
)
} else {
// unknown minutes~
readout = "?m"
readoutAloud = "? minutes"
}
binding.statusMetaInfo.text = readout
binding.statusMetaInfo.contentDescription = readoutAloud
}
}
private fun getIconWithColor(
context: Context,
@DrawableRes drawable: Int,
@ColorRes color: Int
): Drawable? {
val icon = ContextCompat.getDrawable(context, drawable)
icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP)
return icon
}
private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) {
binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0)
loadAvatar(
statusAvatarUrl,
binding.notificationStatusAvatar,
avatarRadius48dp,
animateAvatars
)
if (showBotOverlay && isBot) {
binding.notificationNotificationAvatar.visibility = View.VISIBLE
Glide.with(binding.notificationNotificationAvatar)
.load(R.drawable.bot_badge)
.into(binding.notificationNotificationAvatar)
} else {
binding.notificationNotificationAvatar.visibility = View.GONE
}
}
private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) {
val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12)
binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding)
loadAvatar(
statusAvatarUrl,
binding.notificationStatusAvatar,
avatarRadius36dp,
animateAvatars
)
binding.notificationNotificationAvatar.visibility = View.VISIBLE
loadAvatar(
notificationAvatarUrl,
binding.notificationNotificationAvatar,
avatarRadius24dp,
animateAvatars
)
}
fun setMessage(
notificationViewData: NotificationViewData,
listener: LinkListener,
animateEmojis: Boolean
) {
val statusViewData = notificationViewData.statusViewData
val displayName = notificationViewData.account.name.unicodeWrap()
val type = notificationViewData.type
val context = binding.notificationTopText.context
val format: String
val icon: Drawable?
when (type) {
Notification.Type.FAVOURITE -> {
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
format = context.getString(R.string.notification_favourite_format)
}
Notification.Type.REBLOG -> {
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue)
format = context.getString(R.string.notification_reblog_format)
}
Notification.Type.STATUS -> {
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue)
format = context.getString(R.string.notification_subscription_format)
}
Notification.Type.UPDATE -> {
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue)
format = context.getString(R.string.notification_update_format)
}
else -> {
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
format = context.getString(R.string.notification_favourite_format)
}
}
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(
icon,
null,
null,
null
)
val wholeMessage = String.format(format, displayName)
val str = SpannableStringBuilder(wholeMessage)
val displayNameIndex = format.indexOf("%s")
str.setSpan(
StyleSpan(Typeface.BOLD),
displayNameIndex,
displayNameIndex + displayName.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
val emojifiedText = str.emojify(
notificationViewData.account.emojis,
binding.notificationTopText,
animateEmojis
)
binding.notificationTopText.text = emojifiedText
if (statusViewData != null) {
val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
binding.notificationContentWarningDescription.visibility =
if (hasSpoiler) View.VISIBLE else View.GONE
binding.notificationContentWarningButton.visibility =
if (hasSpoiler) View.VISIBLE else View.GONE
if (statusViewData.isExpanded) {
binding.notificationContentWarningButton.setText(
R.string.post_content_warning_show_less
)
} else {
binding.notificationContentWarningButton.setText(
R.string.post_content_warning_show_more
)
}
binding.notificationContentWarningButton.setOnClickListener {
if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
notificationActionListener.onExpandedChange(
!statusViewData.isExpanded,
bindingAdapterPosition
)
}
binding.notificationContent.visibility =
if (statusViewData.isExpanded) View.GONE else View.VISIBLE
}
setupContentAndSpoiler(listener, statusViewData, animateEmojis)
}
}
private fun setupContentAndSpoiler(
listener: LinkListener,
statusViewData: StatusViewData.Concrete,
animateEmojis: Boolean
) {
val shouldShowContentIfSpoiler = statusViewData.isExpanded
val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
if (!shouldShowContentIfSpoiler && hasSpoiler) {
binding.notificationContent.visibility = View.GONE
} else {
binding.notificationContent.visibility = View.VISIBLE
}
val content = statusViewData.content
val emojis = statusViewData.actionable.emojis
if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) {
binding.buttonToggleNotificationContent.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
notificationActionListener.onNotificationContentCollapsedChange(
!statusViewData.isCollapsed,
position
)
}
}
binding.buttonToggleNotificationContent.visibility = View.VISIBLE
if (statusViewData.isCollapsed) {
binding.buttonToggleNotificationContent.setText(
R.string.post_content_warning_show_more
)
binding.notificationContent.filters = COLLAPSE_INPUT_FILTER
} else {
binding.buttonToggleNotificationContent.setText(
R.string.post_content_warning_show_less
)
binding.notificationContent.filters = NO_INPUT_FILTER
}
} else {
binding.buttonToggleNotificationContent.visibility = View.GONE
binding.notificationContent.filters = NO_INPUT_FILTER
}
val emojifiedText =
content.emojify(
emojis,
binding.notificationContent,
animateEmojis
)
setClickableText(
binding.notificationContent,
emojifiedText,
statusViewData.actionable.mentions,
statusViewData.actionable.tags,
listener
)
val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify(
statusViewData.actionable.emojis,
binding.notificationContentWarningDescription,
animateEmojis
)
binding.notificationContentWarningDescription.text = emojifiedContentWarning
}
private fun setQuoteContainer(quote: StatusViewData.Concrete?, statusDisplayOptions: StatusDisplayOptions) {
binding.statusQuoteInlineContainer.root.visible(quote != null)
if (quote != null) {
QuoteInlineHelper(
binding.statusQuoteInlineContainer,
statusActionListener,
avatarRadius24dp,
statusDisplayOptions,
)
.setupQuoteContainer(quote)
}
}
companion object {
private val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
private val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
}
}

View File

@ -1,60 +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.components.notifications
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.databinding.ItemStatusBinding
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
internal class StatusViewHolder(
binding: ItemStatusBinding,
private val statusActionListener: StatusActionListener,
private val accountId: String
) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) {
override fun bind(
viewData: NotificationViewData,
payloads: List<*>?,
statusDisplayOptions: StatusDisplayOptions
) {
val statusViewData = viewData.statusViewData
if (statusViewData == null) {
// Hide null statuses. Shouldn't happen according to the spec, but some servers
// have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
showStatusContent(false)
} else {
if (payloads.isNullOrEmpty()) {
showStatusContent(true)
}
setupWithStatus(
statusViewData,
statusActionListener,
statusDisplayOptions,
payloads?.firstOrNull()
)
}
if (viewData.type == Notification.Type.POLL) {
setPollInfo(accountId == viewData.account.id)
} else {
hideStatusInfo()
}
}
}

View File

@ -30,9 +30,9 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
import com.keylesspalace.tusky.db.AccountManager
@ -156,7 +156,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
setTitle(R.string.title_domain_mutes)
setIcon(R.drawable.ic_mute_24dp)
setOnPreferenceClickListener {
val intent = Intent(context, InstanceListActivity::class.java)
val intent = Intent(context, DomainBlocksActivity::class.java)
activity?.startActivity(intent)
activity?.overridePendingTransition(
R.anim.slide_from_right,

View File

@ -33,8 +33,9 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding
import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.setAppNightMode
import dagger.android.DispatchingAndroidInjector
@ -145,8 +146,8 @@ class PreferencesActivity :
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"appTheme" -> {
val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT)
APP_THEME -> {
val theme = sharedPreferences.getNonNullString(APP_THEME, AppTheme.DEFAULT.value)
Log.d("activeTheme", theme)
setAppNightMode(theme)
@ -157,9 +158,9 @@ class PreferencesActivity :
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" -> {
PrefKeys.STATUS_TEXT_SIZE, PrefKeys.ABSOLUTE_TIME_VIEW, PrefKeys.SHOW_BOT_OVERLAY, PrefKeys.ANIMATE_GIF_AVATARS, PrefKeys.USE_BLURHASH,
PrefKeys.SHOW_SELF_USERNAME, PrefKeys.SHOW_CARDS_IN_TIMELINES, PrefKeys.CONFIRM_REBLOGS, PrefKeys.CONFIRM_FAVOURITES,
PrefKeys.ENABLE_SWIPE_FOR_TABS, PrefKeys.MAIN_NAV_POSITION, PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE, "viewPagerOffScreenLimit" -> {
restartActivitiesOnBackPressedCallback.isEnabled = true
}
}

View File

@ -81,7 +81,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceCategory(R.string.pref_title_appearance_settings) {
listPreference {
setDefaultValue(AppTheme.NIGHT.value)
setDefaultValue(AppTheme.DEFAULT.value)
setEntries(R.array.app_theme_names)
entryValues = AppTheme.stringValues()
key = PrefKeys.APP_THEME
@ -214,13 +214,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.SHOW_NOTIFICATIONS_FILTER
setTitle(R.string.pref_title_show_notifications_filter)
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.CONFIRM_REBLOGS

View File

@ -19,9 +19,9 @@ import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.checkBoxPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen
import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference
class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@ -29,19 +29,27 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
preferenceCategory(R.string.title_home) { category ->
category.isIconSpaceReserved = false
checkBoxPreference {
switchPreference {
setTitle(R.string.pref_title_show_boosts)
key = PrefKeys.TAB_FILTER_HOME_BOOSTS
setDefaultValue(true)
isIconSpaceReserved = false
}
checkBoxPreference {
switchPreference {
setTitle(R.string.pref_title_show_replies)
key = PrefKeys.TAB_FILTER_HOME_REPLIES
setDefaultValue(true)
isIconSpaceReserved = false
}
switchPreference {
setTitle(R.string.pref_title_show_self_boosts)
setSummary(R.string.pref_title_show_self_boosts_description)
key = PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS
setDefaultValue(true)
isIconSpaceReserved = false
}.apply { dependency = PrefKeys.TAB_FILTER_HOME_BOOSTS }
}
}
}

View File

@ -20,5 +20,5 @@ enum class Screen {
Note,
Done,
Back,
Finish
Finish,
}

View File

@ -103,15 +103,15 @@ class StatusViewHolder(
shouldTrimStatus(viewdata.content),
viewState.isCollapsed(viewdata.id, true),
viewState.isContentShow(viewdata.id, viewdata.status.sensitive),
viewdata.spoilerText
viewdata.status.spoilerText
)
if (viewdata.spoilerText.isBlank()) {
if (viewdata.status.spoilerText.isBlank()) {
setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide()
} else {
val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
val emojiSpoiler = viewdata.status.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
binding.statusContentWarningDescription.text = emojiSpoiler
binding.statusContentWarningDescription.show()
binding.statusContentWarningButton.show()

View File

@ -150,12 +150,12 @@ class ReportStatusesFragment :
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = false,
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
showBotOverlay = false,
useBlurhash = preferences.getBoolean("useBlurhash", true),
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),

View File

@ -127,7 +127,7 @@ class ScheduledStatusActivity :
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_announcements, menu)
menuInflater.inflate(R.menu.activity_scheduled_status, menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@ScheduledStatusActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
@ -163,8 +163,8 @@ class ScheduledStatusActivity :
visibility = item.params.visibility,
scheduledAt = item.scheduledAt,
sensitive = item.params.sensitive,
kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED
)
kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED,
),
)
startActivity(intent)
}

View File

@ -107,7 +107,7 @@ abstract class SearchFragment<T : Any> :
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_timeline, menu)
menuInflater.inflate(R.menu.fragment_search, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20

View File

@ -78,14 +78,14 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
override fun createAdapter(): PagingDataAdapter<StatusViewData.Concrete, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = viewModel.mediaPreviewEnabled,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
useBlurhash = preferences.getBoolean("useBlurhash", true),
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),

View File

@ -46,7 +46,6 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.QuickReplyEvent
import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusEditedEvent
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
@ -293,7 +292,7 @@ class TimelineFragment :
if (actionButtonPresent()) {
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
hideFab = preferences.getBoolean("fabHide", false)
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val composeButton = (activity as ActionButtonActivity).actionButton
@ -322,9 +321,6 @@ class TimelineFragment :
val status = event.status
handleStatusComposeEvent(status)
}
is StatusEditedEvent -> {
handleStatusComposeEvent(event.status)
}
}
}
}
@ -567,7 +563,8 @@ class TimelineFragment :
when (kind) {
TimelineViewModel.Kind.HOME,
TimelineViewModel.Kind.PUBLIC_FEDERATED,
TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh()
TimelineViewModel.Kind.PUBLIC_LOCAL,
TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES -> adapter.refresh()
TimelineViewModel.Kind.USER,
TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) {
adapter.refresh()

View File

@ -1,9 +1,10 @@
package com.keylesspalace.tusky.components.timeline.util
import com.google.gson.JsonParseException
import retrofit2.HttpException
import java.io.IOException
fun Throwable.isExpected() = this is IOException || this is HttpException
fun Throwable.isExpected() = this is IOException || this is HttpException || this is JsonParseException
inline fun <T> ifExpected(
t: Throwable,

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.timeline.viewmodel
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
@ -117,6 +118,7 @@ class CachedTimelineRemoteMediator(
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) {
return ifExpected(e) {
Log.w(TAG, "Failed to load timeline", e)
MediatorResult.Error(e)
}
}
@ -175,4 +177,8 @@ class CachedTimelineRemoteMediator(
}
return overlappedStatuses
}
companion object {
private const val TAG = "CachedTimelineRM"
}
}

View File

@ -27,11 +27,7 @@ import androidx.paging.filter
import androidx.paging.map
import androidx.room.withTransaction
import com.google.gson.Gson
import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST
import com.keylesspalace.tusky.components.timeline.Placeholder
@ -254,19 +250,7 @@ class CachedTimelineViewModel @Inject constructor(
.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
}
override fun handleReblogEvent(reblogEvent: ReblogEvent) {
// handled by CacheUpdater
}
override fun handleFavEvent(favEvent: FavoriteEvent) {
// handled by CacheUpdater
}
override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
// handled by CacheUpdater
}
override fun handlePinEvent(pinEvent: PinEvent) {
override fun handleStatusChangedEvent(status: Status) {
// handled by CacheUpdater
}

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