Compare commits

...

218 Commits

Author SHA1 Message Date
Grishka
1d5b84943d Merge branch 'l10n_master' 2023-11-01 17:38:39 +03:00
Eugen Rochko
14fe992ca5 New translations strings.xml (Turkish) 2023-11-01 14:37:37 +00:00
Grishka
15232bddaf Merge branch 'l10n_master' 2023-11-01 17:35:51 +03:00
Grishka
160ef25621 Also try resolving URLs from link cards 2023-11-01 17:32:50 +03:00
Eugen Rochko
2afb8688a3 New translations strings.xml (Vietnamese) 2023-10-31 11:03:23 +00:00
Eugen Rochko
9d1af035ea New translations strings.xml (Slovenian) 2023-10-31 08:55:58 +00:00
Eugen Rochko
fb7574d814 New translations strings.xml (Persian) 2023-10-30 16:47:36 +01:00
Eugen Rochko
201a3cb9e3 New translations strings.xml (Slovenian) 2023-10-30 15:17:03 +01:00
Eugen Rochko
cc735ee6a1 New translations strings.xml (Turkish) 2023-10-30 13:09:02 +01:00
Eugen Rochko
0165e14ea0 New translations strings.xml (Turkish) 2023-10-30 11:23:35 +01:00
Grishka
97d19605d5 Fix link cards 2023-10-29 14:43:10 +03:00
Grishka
bc490218f9 Open profile and post links in-app (AND-114) 2023-10-29 14:18:29 +03:00
Grishka
6dac05a21d Fix pagination in lists 2023-10-29 09:59:47 +03:00
Grishka
fd3fff6322 fix 2023-10-28 21:24:04 +03:00
Grishka
edb64fff2e Add info banner to the new local timeline 2023-10-28 18:56:15 +03:00
Grishka
fe0e854e72 Remove local timeline from search tab 2023-10-28 18:43:54 +03:00
Eugen Rochko
06c85fb203 New translations strings.xml (Slovenian) 2023-10-27 23:25:59 +02:00
Eugen Rochko
69926c4ae1 New translations strings.xml (Slovenian) 2023-10-27 22:19:05 +02:00
Eugen Rochko
ef44b0a412 New translations strings.xml (Slovenian) 2023-10-27 20:59:33 +02:00
Eugen Rochko
8577ac1027 New translations strings.xml (Armenian) 2023-10-27 17:46:27 +02:00
Eugen Rochko
32da050106 New translations strings.xml (Armenian) 2023-10-27 15:59:36 +02:00
Eugen Rochko
526b74b3ef New translations strings.xml (Basque) 2023-10-27 12:15:13 +02:00
Eugen Rochko
97ab328a9c New translations strings.xml (Basque) 2023-10-27 09:50:11 +02:00
Eugen Rochko
21603eedcf New translations strings.xml (Basque) 2023-10-26 16:45:54 +02:00
Eugen Rochko
7fbef273a1 New translations strings.xml (French) 2023-10-26 12:03:07 +02:00
Eugen Rochko
9e19716504 New translations strings.xml (Icelandic) 2023-10-26 10:47:01 +02:00
Eugen Rochko
b473642ab4 New translations strings.xml (Ukrainian) 2023-10-26 00:20:48 +02:00
Gregory K
fba55f01a0 Merge pull request #722 from untitaker/fix-gboard-garbage-alt-text
Remove garbage alt text from images attached via Gboard
2023-10-25 22:56:13 +03:00
Markus Unterwaditzer
015e63ba66 apply review feedback 2023-10-25 21:50:10 +02:00
Markus Unterwaditzer
d92e2407f3 Remove garbage alt text from images attached via Gboard
See also: https://github.com/tuskyapp/Tusky/pull/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 this app but I
also don't see it getting fixed in Gboard, so here we go.
2023-10-25 14:44:20 +02:00
Eugen Rochko
a4f84fb8cd New translations strings.xml (Finnish) 2023-10-25 12:13:33 +02:00
Eugen Rochko
bfe88745ca New translations strings.xml (Greek) 2023-10-25 12:13:31 +02:00
Grishka
0d334237ba Merge branch 'l10n_master' 2023-10-25 08:15:22 +03:00
Eugen Rochko
fd5cff3fea New translations strings.xml (Italian) 2023-10-24 23:34:04 +02:00
Eugen Rochko
af5b82e9fd New translations strings.xml (Italian) 2023-10-24 22:37:17 +02:00
Eugen Rochko
d3561748c8 New translations strings.xml (Scottish Gaelic) 2023-10-24 14:45:08 +02:00
Eugen Rochko
791a1d804b New translations strings.xml (Japanese) 2023-10-24 03:33:27 +02:00
Eugen Rochko
2442424e3b New translations strings.xml (Chinese Traditional) 2023-10-24 01:10:21 +02:00
Eugen Rochko
0ecedd2820 New translations strings.xml (Thai) 2023-10-23 18:31:33 +02:00
Eugen Rochko
958d62ec0c New translations strings.xml (Persian) 2023-10-23 17:11:47 +02:00
Eugen Rochko
400cfb2141 New translations strings.xml (Galician) 2023-10-23 17:11:44 +02:00
Eugen Rochko
52b860dd8f New translations strings.xml (Vietnamese) 2023-10-23 17:11:43 +02:00
Eugen Rochko
4d57d8d576 New translations strings.xml (Greek) 2023-10-23 17:11:31 +02:00
Eugen Rochko
9a098accd8 New translations strings.xml (German) 2023-10-23 17:11:29 +02:00
Eugen Rochko
62f3b2522c New translations strings.xml (Spanish) 2023-10-23 17:11:24 +02:00
Eugen Rochko
9b48cd2037 New translations strings.xml (Chinese Traditional) 2023-10-23 17:11:21 +02:00
Eugen Rochko
69776d45d1 New translations strings.xml (Swedish) 2023-10-23 17:11:18 +02:00
Eugen Rochko
b8fb2660a4 New translations strings.xml (Japanese) 2023-10-23 17:11:14 +02:00
Eugen Rochko
941281298d New translations strings.xml (Icelandic) 2023-10-23 17:11:13 +02:00
Eugen Rochko
8afc4511a6 New translations strings.xml (Thai) 2023-10-23 17:11:12 +02:00
Grishka
f43ef325ae Following -> Home 2023-10-23 17:12:22 +03:00
Eugen Rochko
bbdc323204 New translations strings.xml (Icelandic) 2023-10-23 15:21:14 +02:00
Eugen Rochko
8ed9fb6276 New translations strings.xml (Chinese Traditional) 2023-10-22 08:53:38 +02:00
Grishka
27cbb70352 Overdraw fix 2023-10-22 05:18:10 +03:00
Grishka
f5b10b516c Show all followers when creating a list 2023-10-22 05:14:21 +03:00
Grishka
5580308968 Improve lists caching and consistency 2023-10-22 05:07:28 +03:00
Eugen Rochko
901c70efc3 New translations strings.xml (Icelandic) 2023-10-21 19:24:31 +02:00
Eugen Rochko
3d44e5d2cc New translations strings.xml (Icelandic) 2023-10-21 18:27:37 +02:00
Eugen Rochko
33ea3da84d New translations strings.xml (Basque) 2023-10-21 11:37:37 +02:00
Grishka
572901ec9d minor fixes 2023-10-21 11:52:15 +03:00
Grishka
965239d215 Apply filters on more screens 2023-10-21 11:44:39 +03:00
Grishka
ac1e5e991e Don't auto-refresh notifications if the user scrolled too far 2023-10-21 11:34:55 +03:00
Eugen Rochko
e97203a6e3 New translations strings.xml (Thai) 2023-10-21 09:36:50 +02:00
Grishka
66b7b127f9 List creation (AND-98) 2023-10-21 09:13:01 +03:00
Eugen Rochko
b3f2987b14 New translations strings.xml (Vietnamese) 2023-10-21 03:54:29 +02:00
Eugen Rochko
c7426453a5 New translations strings.xml (German) 2023-10-21 02:52:53 +02:00
Eugen Rochko
664d5cc4c3 New translations strings.xml (German) 2023-10-21 01:04:51 +02:00
Eugen Rochko
a44e0e036a New translations strings.xml (Persian) 2023-10-18 20:27:30 +02:00
Eugen Rochko
5d54c1bae4 New translations strings.xml (Spanish) 2023-10-18 20:27:29 +02:00
Eugen Rochko
2ef17ba051 New translations strings.xml (Spanish) 2023-10-18 19:15:17 +02:00
Eugen Rochko
f63daf3a4e New translations strings.xml (Thai) 2023-10-18 19:15:16 +02:00
Eugen Rochko
52b079be2a New translations strings.xml (Thai) 2023-10-18 18:11:40 +02:00
Eugen Rochko
efeca17106 New translations strings.xml (Chinese Traditional) 2023-10-18 11:34:03 +02:00
Eugen Rochko
6827166c1d New translations strings.xml (Galician) 2023-10-18 08:51:11 +02:00
Eugen Rochko
04483e61e8 New translations strings.xml (Galician) 2023-10-18 07:49:15 +02:00
Grishka
0ed858b99c Update string 2023-10-18 02:56:13 +03:00
Grishka
9b3e153a4d Add "manage accounts" item to settings 2023-10-18 01:41:10 +03:00
Grishka
e525aef3d9 Improve new posts button animation 2023-10-18 01:37:02 +03:00
Eugen Rochko
22fe174922 New translations strings.xml (Icelandic) 2023-10-17 20:07:09 +02:00
Eugen Rochko
f143da3913 New translations strings.xml (Kabyle) 2023-10-17 04:25:08 +02:00
Eugen Rochko
7e9f41c74b New translations strings.xml (Scottish Gaelic) 2023-10-17 04:25:05 +02:00
Eugen Rochko
a1474d0d29 New translations strings.xml (Filipino) 2023-10-17 04:25:03 +02:00
Eugen Rochko
0dce936ad3 New translations strings.xml (Persian) 2023-10-17 04:24:58 +02:00
Eugen Rochko
6ebbbb4c6c New translations strings.xml (Indonesian) 2023-10-17 04:24:57 +02:00
Eugen Rochko
dc6ddbd0ee New translations strings.xml (Portuguese, Brazilian) 2023-10-17 04:24:56 +02:00
Eugen Rochko
86c81d6b53 New translations strings.xml (Galician) 2023-10-17 04:24:55 +02:00
Eugen Rochko
451a92aa36 New translations strings.xml (Vietnamese) 2023-10-17 04:24:53 +02:00
Eugen Rochko
5c42e67e73 New translations strings.xml (Chinese Simplified) 2023-10-17 04:24:52 +02:00
Eugen Rochko
d20d36d964 New translations strings.xml (Turkish) 2023-10-17 04:24:51 +02:00
Eugen Rochko
1a8d46c71e New translations strings.xml (Slovenian) 2023-10-17 04:24:50 +02:00
Eugen Rochko
91da10eca3 New translations strings.xml (Portuguese) 2023-10-17 04:24:49 +02:00
Eugen Rochko
3bb0dcee53 New translations strings.xml (Polish) 2023-10-17 04:24:48 +02:00
Eugen Rochko
d3fd4b200f New translations strings.xml (Norwegian) 2023-10-17 04:24:47 +02:00
Eugen Rochko
fed96864e1 New translations strings.xml (Dutch) 2023-10-17 04:24:46 +02:00
Eugen Rochko
3b351bea27 New translations strings.xml (Korean) 2023-10-17 04:24:45 +02:00
Eugen Rochko
254e01dca1 New translations strings.xml (Armenian) 2023-10-17 04:24:44 +02:00
Eugen Rochko
19158e1d48 New translations strings.xml (Hungarian) 2023-10-17 04:24:43 +02:00
Eugen Rochko
bffb78fccf New translations strings.xml (Basque) 2023-10-17 04:24:41 +02:00
Eugen Rochko
a3800592a2 New translations strings.xml (Greek) 2023-10-17 04:24:39 +02:00
Eugen Rochko
22a498dfc9 New translations strings.xml (German) 2023-10-17 04:24:38 +02:00
Eugen Rochko
9ea96e32bd New translations strings.xml (Danish) 2023-10-17 04:24:37 +02:00
Eugen Rochko
68f51a123e New translations strings.xml (Czech) 2023-10-17 04:24:36 +02:00
Eugen Rochko
43b1b63581 New translations strings.xml (Catalan) 2023-10-17 04:24:35 +02:00
Eugen Rochko
43b4a2c515 New translations strings.xml (Belarusian) 2023-10-17 04:24:34 +02:00
Eugen Rochko
5b9cfdb689 New translations strings.xml (Arabic) 2023-10-17 04:24:33 +02:00
Eugen Rochko
b43cd7103a New translations strings.xml (Spanish) 2023-10-17 04:24:32 +02:00
Eugen Rochko
30e4f6e0f5 New translations strings.xml (French) 2023-10-17 04:24:30 +02:00
Eugen Rochko
1d0ebf889b New translations strings.xml (Chinese Traditional) 2023-10-17 04:24:28 +02:00
Eugen Rochko
7c4f1da485 New translations strings.xml (Finnish) 2023-10-17 04:24:27 +02:00
Eugen Rochko
8163921014 New translations strings.xml (Russian) 2023-10-17 04:24:26 +02:00
Eugen Rochko
993393dd96 New translations strings.xml (Swedish) 2023-10-17 04:24:25 +02:00
Eugen Rochko
82aed43934 New translations strings.xml (Italian) 2023-10-17 04:24:24 +02:00
Eugen Rochko
fa65134c26 New translations strings.xml (Ukrainian) 2023-10-17 04:24:23 +02:00
Eugen Rochko
af4266c739 New translations strings.xml (Japanese) 2023-10-17 04:24:22 +02:00
Eugen Rochko
f72ea2e763 New translations strings.xml (Icelandic) 2023-10-17 04:24:21 +02:00
Eugen Rochko
c5540270a3 New translations strings.xml (Thai) 2023-10-17 04:24:20 +02:00
Grishka
201b72c9c8 Add "copy link" to post context menu 2023-10-17 04:41:18 +03:00
Grishka
26b99f5f68 New and improved™ "new posts" button (AND-102) 2023-10-17 04:31:07 +03:00
Grishka
d3dc774492 Empty states for home timeline popup submenus (AND-99) 2023-10-17 03:22:23 +03:00
Grishka
1f7155a932 Reorder notification tabs and remember selection (AND-82) 2023-10-17 03:00:05 +03:00
Grishka
02729fe02b Always allow marking notifications as read 2023-10-17 02:47:50 +03:00
Eugen Rochko
498078b6e0 New translations strings.xml (Swedish) 2023-10-15 20:37:55 +02:00
Eugen Rochko
526f5e319b New translations strings.xml (Swedish) 2023-10-15 18:51:35 +02:00
Eugen Rochko
d419dba44a New translations strings.xml (Swedish) 2023-10-15 14:26:36 +02:00
Eugen Rochko
fd98159fce New translations strings.xml (Greek) 2023-10-14 00:27:35 +02:00
Gregory K
f5b98009dd Merge pull request #712 from mastodon/remove-funding
Delete .github/FUNDING.yml
2023-10-13 14:08:47 +03:00
Renaud Chaput
cf0b66d852 Delete .github/FUNDING.yml
We now have an organisation-wide funding configuration, this one is no longer needed
2023-10-13 10:45:53 +02:00
Eugen Rochko
197d0caf44 New translations strings.xml (Japanese) 2023-10-11 18:34:49 +02:00
Eugen Rochko
a4a082f76a New translations strings.xml (Japanese) 2023-10-11 17:14:57 +02:00
Grishka
84026afb92 A bunch of minor RTL fixes 2023-10-11 00:49:40 +03:00
Grishka
4dea7d2a52 Retain CWs but expand them when showCWs setting is off
#323, closes #87
2023-10-11 00:27:26 +03:00
Grishka
2df1b7dd61 Remove repeating logging code from MastodonAPIController 2023-10-10 23:47:36 +03:00
Grishka
89042113a5 Fix default server loading timeout 2023-10-10 23:43:44 +03:00
Grishka
48665ebcce Scroll search to top on query change 2023-10-10 23:28:09 +03:00
Eugen Rochko
2528d48010 New translations strings.xml (Icelandic) 2023-10-10 18:10:41 +02:00
Eugen Rochko
5456d71979 New translations strings.xml (Chinese Traditional) 2023-10-10 10:25:33 +02:00
Eugen Rochko
e36aae3cf3 New translations strings.xml (Thai) 2023-10-09 20:31:09 +02:00
Eugen Rochko
6d12e2dd72 New translations strings.xml (Thai) 2023-10-09 19:20:32 +02:00
Eugen Rochko
f117249bb5 New translations strings.xml (Thai) 2023-10-09 18:02:31 +02:00
Eugen Rochko
cf1d537367 New translations strings.xml (Vietnamese) 2023-10-09 06:03:28 +02:00
Eugen Rochko
517d13b400 New translations strings.xml (Vietnamese) 2023-10-09 05:07:06 +02:00
Grishka
103aaafff1 Hide the FAB while editing profile
closes #709
2023-10-08 22:31:27 +03:00
Eugen Rochko
fae870c93a New translations strings.xml (Urdu (India)) 2023-10-08 21:13:23 +02:00
Eugen Rochko
f8e00dcc80 New translations strings.xml (Kabyle) 2023-10-08 21:13:22 +02:00
Eugen Rochko
5fdbb597bb New translations strings.xml (Igbo) 2023-10-08 21:13:21 +02:00
Eugen Rochko
d74b286a9d New translations strings.xml (Occitan) 2023-10-08 21:13:20 +02:00
Eugen Rochko
ecb3c521ff New translations strings.xml (Scottish Gaelic) 2023-10-08 21:13:19 +02:00
Eugen Rochko
1d093ce928 New translations strings.xml (Sinhala) 2023-10-08 21:13:18 +02:00
Eugen Rochko
46b711af2e New translations strings.xml (Bosnian) 2023-10-08 21:13:17 +02:00
Eugen Rochko
772e6ddb5d New translations strings.xml (Filipino) 2023-10-08 21:13:16 +02:00
Eugen Rochko
f84e8443d2 New translations strings.xml (Burmese) 2023-10-08 21:13:15 +02:00
Eugen Rochko
250c18ebf1 New translations strings.xml (Hindi) 2023-10-08 21:13:14 +02:00
Eugen Rochko
e3d0f38b79 New translations strings.xml (Croatian) 2023-10-08 21:13:14 +02:00
Eugen Rochko
c512f97783 New translations strings.xml (Bengali) 2023-10-08 21:13:13 +02:00
Eugen Rochko
0594680775 New translations strings.xml (Persian) 2023-10-08 21:13:12 +02:00
Eugen Rochko
f999881f59 New translations strings.xml (Indonesian) 2023-10-08 21:13:11 +02:00
Eugen Rochko
4fe9192ac6 New translations strings.xml (Portuguese, Brazilian) 2023-10-08 21:13:10 +02:00
Eugen Rochko
d936702fa9 New translations strings.xml (Galician) 2023-10-08 21:13:08 +02:00
Eugen Rochko
74e284b0de New translations strings.xml (Vietnamese) 2023-10-08 21:13:07 +02:00
Eugen Rochko
4c42b72ed8 New translations strings.xml (Chinese Simplified) 2023-10-08 21:13:06 +02:00
Eugen Rochko
0e0046df65 New translations strings.xml (Turkish) 2023-10-08 21:13:05 +02:00
Eugen Rochko
c80d1d10c2 New translations strings.xml (Slovenian) 2023-10-08 21:13:05 +02:00
Eugen Rochko
da97971011 New translations strings.xml (Portuguese) 2023-10-08 21:13:04 +02:00
Eugen Rochko
700447dbe7 New translations strings.xml (Polish) 2023-10-08 21:13:03 +02:00
Eugen Rochko
37e7b5ee93 New translations strings.xml (Norwegian) 2023-10-08 21:13:02 +02:00
Eugen Rochko
1265afa93f New translations strings.xml (Dutch) 2023-10-08 21:13:01 +02:00
Eugen Rochko
1e09481b02 New translations strings.xml (Korean) 2023-10-08 21:13:00 +02:00
Eugen Rochko
9996a5a05e New translations strings.xml (Armenian) 2023-10-08 21:12:59 +02:00
Eugen Rochko
f20aac7c81 New translations strings.xml (Hungarian) 2023-10-08 21:12:58 +02:00
Eugen Rochko
98f7b0bacd New translations strings.xml (Hebrew) 2023-10-08 21:12:57 +02:00
Eugen Rochko
3f6d3fb3a2 New translations strings.xml (Irish) 2023-10-08 21:12:56 +02:00
Eugen Rochko
663b49c76b New translations strings.xml (Basque) 2023-10-08 21:12:55 +02:00
Eugen Rochko
16e38f2541 New translations strings.xml (Greek) 2023-10-08 21:12:54 +02:00
Eugen Rochko
842cc55e47 New translations strings.xml (German) 2023-10-08 21:12:53 +02:00
Eugen Rochko
72db099e6f New translations strings.xml (Danish) 2023-10-08 21:12:52 +02:00
Eugen Rochko
be130bc3a7 New translations strings.xml (Czech) 2023-10-08 21:12:51 +02:00
Eugen Rochko
42253336e1 New translations strings.xml (Catalan) 2023-10-08 21:12:50 +02:00
Eugen Rochko
572631e1d7 New translations strings.xml (Belarusian) 2023-10-08 21:12:49 +02:00
Eugen Rochko
723777a800 New translations strings.xml (Arabic) 2023-10-08 21:12:48 +02:00
Eugen Rochko
b825d534c1 New translations strings.xml (Spanish) 2023-10-08 21:12:47 +02:00
Eugen Rochko
b9749620a8 New translations strings.xml (French) 2023-10-08 21:12:46 +02:00
Eugen Rochko
70ea9989aa New translations strings.xml (Romanian) 2023-10-08 21:12:45 +02:00
Eugen Rochko
b3ec9c981c New translations strings.xml (Chinese Traditional) 2023-10-08 21:12:44 +02:00
Eugen Rochko
bf72085abb New translations strings.xml (Finnish) 2023-10-08 21:12:43 +02:00
Eugen Rochko
64dd416b59 New translations strings.xml (Russian) 2023-10-08 21:12:42 +02:00
Eugen Rochko
ab2a920455 New translations strings.xml (Swedish) 2023-10-08 21:12:41 +02:00
Eugen Rochko
7580446d60 New translations strings.xml (Italian) 2023-10-08 21:12:40 +02:00
Eugen Rochko
ade18ac6fc New translations strings.xml (Ukrainian) 2023-10-08 21:12:39 +02:00
Eugen Rochko
005c851d72 New translations strings.xml (Japanese) 2023-10-08 21:12:38 +02:00
Eugen Rochko
0f1d46c765 New translations strings.xml (Icelandic) 2023-10-08 21:12:36 +02:00
Eugen Rochko
21fbb07b1d New translations strings.xml (Thai) 2023-10-08 21:12:36 +02:00
Grishka
dff2217e80 Lists
closes #89, closes #279
2023-10-08 22:03:16 +03:00
Eugen Rochko
22aac3d943 New translations strings.xml (Finnish) 2023-10-07 22:49:20 +02:00
Eugen Rochko
53afc120f3 New translations strings.xml (Chinese Traditional) 2023-10-07 03:46:12 +02:00
Eugen Rochko
a75ce70615 New translations strings.xml (Finnish) 2023-10-06 16:38:40 +02:00
Eugen Rochko
4a3b948760 New translations strings.xml (Finnish) 2023-10-05 22:23:12 +02:00
Eugen Rochko
f81283c892 New translations strings.xml (Italian) 2023-10-05 21:19:39 +02:00
Eugen Rochko
7eae879037 New translations strings.xml (Russian) 2023-10-05 16:06:00 +02:00
Eugen Rochko
1b0ce5d893 New translations strings.xml (Swedish) 2023-10-05 12:02:19 +02:00
Eugen Rochko
c17745368d New translations strings.xml (Italian) 2023-10-04 22:24:20 +02:00
Eugen Rochko
e78b518654 New translations strings.xml (Ukrainian) 2023-10-04 18:44:26 +02:00
Eugen Rochko
55a8634be2 New translations strings.xml (Japanese) 2023-10-04 16:52:54 +02:00
Eugen Rochko
ac891eea53 New translations strings.xml (Icelandic) 2023-10-04 15:28:52 +02:00
Eugen Rochko
74fa2a3081 New translations strings.xml (Thai) 2023-10-03 21:27:27 +02:00
Grishka
6c1c5b7759 Merge branch 'l10n_master' 2023-10-03 03:53:50 +03:00
Grishka
1f4152b588 Fix #705 and improve handling of unknown attachment dimensions 2023-10-03 02:52:07 +03:00
Grishka
70386ea1b2 Update appkit to finally fix that ViewPager2 crash 2023-10-03 02:11:04 +03:00
Eugen Rochko
cbce90c461 New translations strings.xml (Sinhala) 2023-10-02 21:16:49 +02:00
Eugen Rochko
74ae3bf706 New translations strings.xml (Armenian) 2023-10-02 07:26:27 +02:00
Grishka
1feccdc26d Fixes 2023-10-01 23:11:33 +03:00
Eugen Rochko
c38c2a425b New translations strings.xml (Indonesian) 2023-10-01 17:30:46 +02:00
Eugen Rochko
f43352b790 New translations strings.xml (Indonesian) 2023-10-01 16:30:51 +02:00
Grishka
c5b52b2781 Fix default server not loading sometimes 2023-10-01 12:17:21 +03:00
Grishka
b91840fb95 Another attempt to fix ZoomPanView crash 2023-10-01 07:16:21 +03:00
Grishka
fc10fbffb0 Clear fragment stack instead of restarting activity
grishka/appkit#13
2023-09-30 21:53:02 +03:00
Eugen Rochko
e40841c128 New translations strings.xml (Portuguese, Brazilian) 2023-09-30 20:26:54 +02:00
Eugen Rochko
98a02e874b New translations strings.xml (Vietnamese) 2023-09-30 15:05:49 +02:00
Eugen Rochko
b06df8c3d0 New translations strings.xml (Galician) 2023-09-29 07:53:31 +02:00
193 changed files with 6186 additions and 613 deletions

12
.github/FUNDING.yml vendored
View File

@@ -1,12 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: mastodon
open_collective: # Replace with a single Open Collective username e.g., user1
ko_fi: # Replace with a single Ko-fi username e.g., user1
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username e.g., user1
issuehunt: # Replace with a single IssueHunt username e.g., user1
otechie: # Replace with a single Otechie username e.g., user1
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -9,8 +9,8 @@ android {
applicationId "org.joinmastodon.android"
minSdk 23
targetSdk 33
versionCode 70
versionName "2.1.4"
versionCode 76
versionName "2.2.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs "ar-rSA", "be-rBY", "bn-rBD", "bs-rBA", "ca-rES", "cs-rCZ", "da-rDK", "de-rDE", "el-rGR", "es-rES", "eu-rES", "fa-rIR", "fi-rFI", "fil-rPH", "fr-rFR", "ga-rIE", "gd-rGB", "gl-rES", "hi-rIN", "hr-rHR", "hu-rHU", "hy-rAM", "ig-rNG", "in-rID", "is-rIS", "it-rIT", "iw-rIL", "ja-rJP", "kab", "ko-rKR", "my-rMM", "nl-rNL", "no-rNO", "oc-rFR", "pl-rPL", "pt-rBR", "pt-rPT", "ro-rRO", "ru-rRU", "si-rLK", "sl-rSI", "sv-rSE", "th-rTH", "tr-rTR", "uk-rUA", "ur-rIN", "vi-rVN", "zh-rCN", "zh-rTW"
}
@@ -76,7 +76,7 @@ dependencies {
implementation 'me.grishka.litex:viewpager:1.0.0'
implementation 'me.grishka.litex:viewpager2:1.0.0'
implementation 'me.grishka.litex:palette:1.0.0'
implementation 'me.grishka.appkit:appkit:1.2.13'
implementation 'me.grishka.appkit:appkit:1.2.15'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.14.3'
implementation 'com.squareup:otto:1.3.8'

View File

@@ -42,40 +42,7 @@ public class MainActivity extends FragmentStackActivity{
super.onCreate(savedInstanceState);
if(savedInstanceState==null){
if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){
showFragmentClearingBackStack(new SplashFragment());
}else{
AccountSessionManager.getInstance().maybeUpdateLocalInfo();
AccountSession session;
Bundle args=new Bundle();
Intent intent=getIntent();
if(intent.getBooleanExtra("fromNotification", false)){
String accountID=intent.getStringExtra("accountID");
try{
session=AccountSessionManager.getInstance().getAccount(accountID);
if(!intent.hasExtra("notification"))
args.putString("tab", "notifications");
}catch(IllegalStateException x){
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
}else{
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
args.putString("account", session.getID());
Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment();
fragment.setArguments(args);
showFragmentClearingBackStack(fragment);
if(intent.getBooleanExtra("fromNotification", false) && intent.hasExtra("notification")){
Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification"));
showFragmentForNotification(notification, session.getID());
}else if(intent.getBooleanExtra("compose", false)){
showCompose();
}else if(Intent.ACTION_VIEW.equals(intent.getAction())){
handleURL(intent.getData(), null);
}else{
maybeRequestNotificationsPermission();
}
}
restartHomeFragment();
}
if(BuildConfig.BUILD_TYPE.startsWith("appcenter")){
@@ -200,4 +167,41 @@ public class MainActivity extends FragmentStackActivity{
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 100);
}
}
public void restartHomeFragment(){
if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){
showFragmentClearingBackStack(new SplashFragment());
}else{
AccountSessionManager.getInstance().maybeUpdateLocalInfo();
AccountSession session;
Bundle args=new Bundle();
Intent intent=getIntent();
if(intent.getBooleanExtra("fromNotification", false)){
String accountID=intent.getStringExtra("accountID");
try{
session=AccountSessionManager.getInstance().getAccount(accountID);
if(!intent.hasExtra("notification"))
args.putString("tab", "notifications");
}catch(IllegalStateException x){
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
}else{
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
args.putString("account", session.getID());
Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment();
fragment.setArguments(args);
showFragmentClearingBackStack(fragment);
if(intent.getBooleanExtra("fromNotification", false) && intent.hasExtra("notification")){
Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification"));
showFragmentForNotification(notification, session.getID());
}else if(intent.getBooleanExtra("compose", false)){
showCompose();
}else if(Intent.ACTION_VIEW.equals(intent.getAction())){
handleURL(intent.getData(), null);
}else{
maybeRequestNotificationsPermission();
}
}
}
}

View File

@@ -9,20 +9,31 @@ import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.function.Consumer;
@@ -42,6 +53,7 @@ public class CacheController{
private final Runnable databaseCloseRunnable=this::closeDatabase;
private boolean loadingNotifications;
private final ArrayList<Callback<PaginatedResponse<List<Notification>>>> pendingNotificationsCallbacks=new ArrayList<>();
private List<FollowList> lists;
private static final int POST_FLAG_GAP_AFTER=1;
@@ -300,6 +312,99 @@ public class CacheController{
}, 0);
}
public void reloadLists(Callback<List<FollowList>> callback){
new GetLists()
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<FollowList> result){
result.sort(Comparator.comparing(l->l.title));
lists=result;
if(callback!=null)
callback.onSuccess(result);
writeListsToFile();
}
@Override
public void onError(ErrorResponse error){
if(callback!=null)
callback.onError(error);
}
})
.exec(accountID);
}
private List<FollowList> loadListsFromFile(){
File file=getListsFile();
if(!file.exists())
return null;
try(InputStreamReader in=new InputStreamReader(new FileInputStream(file))){
return MastodonAPIController.gson.fromJson(in, new TypeToken<List<FollowList>>(){}.getType());
}catch(Exception x){
Log.w(TAG, "failed to read lists from cache file", x);
return null;
}
}
private void writeListsToFile(){
databaseThread.postRunnable(()->{
try(OutputStreamWriter out=new OutputStreamWriter(new FileOutputStream(getListsFile()))){
MastodonAPIController.gson.toJson(lists, out);
}catch(IOException x){
Log.w(TAG, "failed to write lists to cache file", x);
}
}, 0);
}
public void getLists(Callback<List<FollowList>> callback){
if(lists!=null){
if(callback!=null)
callback.onSuccess(lists);
return;
}
databaseThread.postRunnable(()->{
List<FollowList> lists=loadListsFromFile();
if(lists!=null){
this.lists=lists;
if(callback!=null)
uiHandler.post(()->callback.onSuccess(lists));
return;
}
reloadLists(callback);
}, 0);
}
public File getListsFile(){
return new File(MastodonApp.context.getFilesDir(), "lists_"+accountID+".json");
}
public void addList(FollowList list){
if(lists==null)
return;
lists.add(list);
lists.sort(Comparator.comparing(l->l.title));
writeListsToFile();
}
public void deleteList(String id){
if(lists==null)
return;
lists.removeIf(l->l.id.equals(id));
writeListsToFile();
}
public void updateList(FollowList list){
if(lists==null)
return;
for(int i=0;i<lists.size();i++){
if(lists.get(i).id.equals(list.id)){
lists.set(i, list);
lists.sort(Comparator.comparing(l->l.title));
writeListsToFile();
break;
}
}
}
private class DatabaseHelper extends SQLiteOpenHelper{
public DatabaseHelper(){

View File

@@ -92,15 +92,15 @@ public class MastodonAPIController{
}
if(BuildConfig.DEBUG)
Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] Sending request: "+hreq);
Log.d(TAG, logTag(session)+"Sending request: "+hreq);
call.enqueue(new Callback(){
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e){
if(call.isCanceled())
if(req.canceled)
return;
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed", e);
Log.w(TAG, logTag(session)+""+hreq+" failed", e);
synchronized(req){
req.okhttpCall=null;
}
@@ -109,10 +109,10 @@ public class MastodonAPIController{
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{
if(call.isCanceled())
if(req.canceled)
return;
if(BuildConfig.DEBUG)
Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" received response: "+response);
Log.d(TAG, logTag(session)+hreq+" received response: "+response);
synchronized(req){
req.okhttpCall=null;
}
@@ -123,7 +123,7 @@ public class MastodonAPIController{
try{
if(BuildConfig.DEBUG){
JsonElement respJson=JsonParser.parseReader(reader);
Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] response body: "+respJson);
Log.d(TAG, logTag(session)+"response body: "+respJson);
if(req.respTypeToken!=null)
respObj=gson.fromJson(respJson, req.respTypeToken.getType());
else if(req.respClass!=null)
@@ -140,7 +140,7 @@ public class MastodonAPIController{
}
}catch(JsonIOException|JsonSyntaxException x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x);
Log.w(TAG, logTag(session)+response+" error parsing or reading body", x);
req.onError(x.getLocalizedMessage(), response.code(), x);
return;
}
@@ -149,19 +149,19 @@ public class MastodonAPIController{
req.validateAndPostprocessResponse(respObj, response);
}catch(IOException x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x);
Log.w(TAG, logTag(session)+response+" error post-processing or validating response", x);
req.onError(x.getLocalizedMessage(), response.code(), x);
return;
}
if(BuildConfig.DEBUG)
Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" parsed successfully: "+respObj);
Log.d(TAG, logTag(session)+response+" parsed successfully: "+respObj);
req.onSuccess(respObj);
}else{
try{
JsonObject error=JsonParser.parseReader(reader).getAsJsonObject();
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" received error: "+error);
Log.w(TAG, logTag(session)+response+" received error: "+error);
if(error.has("details")){
MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null);
HashMap<String, List<MastodonDetailedErrorResponse.FieldError>> details=new HashMap<>();
@@ -196,7 +196,7 @@ public class MastodonAPIController{
});
}catch(Exception x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x);
Log.w(TAG, logTag(session)+"error creating and sending http request", x);
req.onError(x.getLocalizedMessage(), 0, x);
}
}, 0);
@@ -209,4 +209,8 @@ public class MastodonAPIController{
public static OkHttpClient getHttpClient(){
return httpClient;
}
private static String logTag(AccountSession session){
return "["+(session==null ? "no-auth" : session.getID())+"] ";
}
}

View File

@@ -154,6 +154,8 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
}
public RequestBody getRequestBody() throws IOException{
if(requestBody instanceof RequestBody rb)
return rb;
return requestBody==null ? null : new JsonObjectRequestBody(requestBody);
}

View File

@@ -0,0 +1,14 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
import java.util.List;
public class GetAccountLists extends MastodonAPIRequest<List<FollowList>>{
public GetAccountLists(String id){
super(HttpMethod.GET, "/accounts/"+id+"/lists", new TypeToken<>(){});
}
}

View File

@@ -0,0 +1,23 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Account;
import java.util.List;
public class SearchAccounts extends MastodonAPIRequest<List<Account>>{
public SearchAccounts(String q, int limit, int offset, boolean resolve, boolean following){
super(HttpMethod.GET, "/accounts/search", new TypeToken<>(){});
addQueryParameter("q", q);
if(limit>0)
addQueryParameter("limit", limit+"");
if(offset>0)
addQueryParameter("offset", offset+"");
if(resolve)
addQueryParameter("resolve", "true");
if(following)
addQueryParameter("following", "true");
}
}

View File

@@ -2,6 +2,9 @@ package org.joinmastodon.android.api.requests.filters;
import com.google.gson.annotations.SerializedName;
import androidx.annotation.Keep;
@Keep
class KeywordAttribute{
public String id;
@SerializedName("_destroy")

View File

@@ -0,0 +1,19 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import okhttp3.FormBody;
public class AddAccountsToList extends ResultlessMastodonAPIRequest{
public AddAccountsToList(String listID, Collection<String> accountIDs){
super(HttpMethod.POST, "/lists/"+listID+"/accounts");
FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8);
for(String id:accountIDs){
builder.add("account_ids[]", id);
}
setRequestBody(builder.build());
}
}

View File

@@ -0,0 +1,23 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
public class CreateList extends MastodonAPIRequest<FollowList>{
public CreateList(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){
super(HttpMethod.POST, "/lists", FollowList.class);
setRequestBody(new Request(title, repliesPolicy, exclusive));
}
private static class Request{
public String title;
public FollowList.RepliesPolicy repliesPolicy;
public boolean exclusive;
public Request(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){
this.title=title;
this.repliesPolicy=repliesPolicy;
this.exclusive=exclusive;
}
}
}

View File

@@ -0,0 +1,9 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
public class DeleteList extends ResultlessMastodonAPIRequest{
public DeleteList(String id){
super(HttpMethod.DELETE, "/lists/"+id);
}
}

View File

@@ -0,0 +1,17 @@
package org.joinmastodon.android.api.requests.lists;
import android.text.TextUtils;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetListAccounts extends HeaderPaginationRequest<Account>{
public GetListAccounts(String listID, String maxID, int limit){
super(HttpMethod.GET, "/lists/"+listID+"/accounts", new TypeToken<>(){});
if(!TextUtils.isEmpty(maxID))
addQueryParameter("max_id", maxID);
addQueryParameter("limit", String.valueOf(limit));
}
}

View File

@@ -0,0 +1,14 @@
package org.joinmastodon.android.api.requests.lists;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
import java.util.List;
public class GetLists extends MastodonAPIRequest<List<FollowList>>{
public GetLists(){
super(HttpMethod.GET, "/lists", new TypeToken<>(){});
}
}

View File

@@ -0,0 +1,19 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import okhttp3.FormBody;
public class RemoveAccountsFromList extends ResultlessMastodonAPIRequest{
public RemoveAccountsFromList(String listID, Collection<String> accountIDs){
super(HttpMethod.DELETE, "/lists/"+listID+"/accounts");
FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8);
for(String id:accountIDs){
builder.add("account_ids[]", id);
}
setRequestBody(builder.build());
}
}

View File

@@ -0,0 +1,23 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
public class UpdateList extends MastodonAPIRequest<FollowList>{
public UpdateList(String listID, String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){
super(HttpMethod.PUT, "/lists/"+listID, FollowList.class);
setRequestBody(new Request(title, repliesPolicy, exclusive));
}
private static class Request{
public String title;
public FollowList.RepliesPolicy repliesPolicy;
public boolean exclusive;
public Request(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){
this.title=title;
this.repliesPolicy=repliesPolicy;
this.exclusive=exclusive;
}
}
}

View File

@@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.tags;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Hashtag;
public class GetFollowedTags extends HeaderPaginationRequest<Hashtag>{
public GetFollowedTags(String maxID, int limit){
super(HttpMethod.GET, "/followed_tags", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View File

@@ -0,0 +1,22 @@
package org.joinmastodon.android.api.requests.timelines;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetListTimeline extends MastodonAPIRequest<List<Status>>{
public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID){
super(HttpMethod.GET, "/timelines/list/"+listID, new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(limit>0)
addQueryParameter("limit", ""+limit);
if(sinceID!=null)
addQueryParameter("since_id", sinceID);
}
}

View File

@@ -10,7 +10,7 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{
public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit){
public GetPublicTimeline(boolean local, boolean remote, String maxID, String minID, int limit, String sinceID){
super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){});
if(local)
addQueryParameter("local", "true");
@@ -18,6 +18,10 @@ public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{
addQueryParameter("remote", "true");
if(!TextUtils.isEmpty(maxID))
addQueryParameter("max_id", maxID);
if(!TextUtils.isEmpty(minID))
addQueryParameter("min_id", minID);
if(!TextUtils.isEmpty(sinceID))
addQueryParameter("since_id", sinceID);
if(limit>0)
addQueryParameter("limit", limit+"");
}

View File

@@ -24,6 +24,7 @@ import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.PushSubscription;
@@ -32,6 +33,7 @@ import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.model.Token;
import org.joinmastodon.android.utils.ObjectIdComparator;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -66,6 +68,7 @@ public class AccountSession{
private transient SharedPreferences prefs;
private transient boolean preferencesNeedSaving;
private transient AccountLocalPreferences localPreferences;
private transient List<FollowList> lists;
AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){
this.token=token;
@@ -265,4 +268,12 @@ public class AccountSession{
public void updateAccountInfo(){
AccountSessionManager.getInstance().updateSessionLocalInfo(this);
}
public boolean isNotificationsMentionsOnly(){
return getRawLocalPreferences().getBoolean("notificationsMentionsOnly", false);
}
public void setNotificationsMentionsOnly(boolean mentionsOnly){
getRawLocalPreferences().edit().putBoolean("notificationsMentionsOnly", mentionsOnly).apply();
}
}

View File

@@ -175,6 +175,7 @@ public class AccountSessionManager{
public void removeAccount(String id){
AccountSession session=getAccount(id);
session.getCacheController().closeDatabase();
session.getCacheController().getListsFile().delete();
MastodonApp.context.deleteDatabase(id+".db");
MastodonApp.context.getSharedPreferences(id, 0).edit().clear().commit();
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){

View File

@@ -0,0 +1,15 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Account;
public class AccountAddedToListEvent{
public final String accountID;
public final String listID;
public final Account account;
public AccountAddedToListEvent(String accountID, String listID, Account account){
this.accountID=accountID;
this.listID=listID;
this.account=account;
}
}

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.events;
public class AccountRemovedFromListEvent{
public final String accountID;
public final String listID;
public final String targetAccountID;
public AccountRemovedFromListEvent(String accountID, String listID, String targetAccountID){
this.accountID=accountID;
this.listID=listID;
this.targetAccountID=targetAccountID;
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
public class FinishListCreationFragmentEvent{
public final String accountID;
public final String listID;
public FinishListCreationFragmentEvent(String accountID, String listID){
this.accountID=accountID;
this.listID=listID;
}
}

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.FollowList;
public class ListCreatedEvent{
public final String accountID;
public final FollowList list;
public ListCreatedEvent(String accountID, FollowList list){
this.accountID=accountID;
this.list=list;
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
public class ListDeletedEvent{
public final String accountID;
public final String listID;
public ListDeletedEvent(String accountID, String listID){
this.accountID=accountID;
this.listID=listID;
}
}

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.FollowList;
public class ListUpdatedEvent{
public final String accountID;
public final FollowList list;
public ListUpdatedEvent(String accountID, FollowList list){
this.accountID=accountID;
this.list=list;
}
}

View File

@@ -0,0 +1,114 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.widget.TextView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountLists;
import org.joinmastodon.android.api.requests.lists.AddAccountsToList;
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.AccountAddedToListEvent;
import org.joinmastodon.android.events.AccountRemovedFromListEvent;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class AddAccountToListsFragment extends BaseSettingsFragment<FollowList>{
private Account account;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.add_user_to_list_title);
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
loadData();
}
@Override
protected void doLoadData(int offset, int count){
AccountSessionManager.get(accountID).getCacheController().getLists(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowList> allLists){
if(getActivity()==null)
return;
loadAccountLists(allLists);
}
});
}
private void loadAccountLists(final List<FollowList> allLists){
currentRequest=new GetAccountLists(account.id)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowList> result){
Set<String> lists=result.stream().map(l->l.id).collect(Collectors.toSet());
onDataLoaded(allLists.stream()
.map(l->new CheckableListItem<>(l.title, null, CheckableListItem.Style.CHECKBOX, lists.contains(l.id),
R.drawable.ic_list_alt_24px, AddAccountToListsFragment.this::onItemClick, l))
.collect(Collectors.toList()), false);
}
})
.exec(accountID);
}
@Override
protected int indexOfItemsAdapter(){
return 1;
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
TextView topText=new TextView(getActivity());
topText.setTextAppearance(R.style.m3_body_medium);
topText.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface));
topText.setPadding(V.dp(16), V.dp(8), V.dp(16), V.dp(8));
topText.setText(getString(R.string.manage_user_lists, account.getDisplayUsername()));
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(topText));
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
}
private void onItemClick(CheckableListItem<FollowList> item){
boolean add=!item.checked;
ResultlessMastodonAPIRequest req=add ? new AddAccountsToList(item.parentObject.id, Set.of(account.id)) : new RemoveAccountsFromList(item.parentObject.id, Set.of(account.id));
req.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
item.checked=add;
rebindItem(item);
if(add){
E.post(new AccountAddedToListEvent(accountID, item.parentObject.id, account));
}else{
E.post(new AccountRemovedFromListEvent(accountID, item.parentObject.id, account.id));
}
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, false)
.exec(accountID);
}
}

View File

@@ -0,0 +1,176 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.DeleteList;
import org.joinmastodon.android.api.requests.lists.GetListAccounts;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.ListDeletedEvent;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.AvatarPileListItem;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public abstract class BaseEditListFragment extends BaseSettingsFragment<Void>{
protected FollowList followList;
protected AvatarPileListItem<Void> membersItem;
protected CheckableListItem<Void> exclusiveItem;
protected FloatingHintEditTextLayout titleEditLayout;
protected EditText titleEdit;
protected Spinner showRepliesSpinner;
private APIRequest<?> getMembersRequest;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
membersItem=new AvatarPileListItem<>(getString(R.string.list_members), null, List.of(), 0, i->onMembersClick(), null, false);
List<ListItem<Void>> items=new ArrayList<>();
if(followList!=null){
items.add(membersItem);
}
exclusiveItem=new CheckableListItem<>(R.string.list_exclusive, R.string.list_exclusive_subtitle, CheckableListItem.Style.SWITCH, followList!=null && followList.exclusive, this::toggleCheckableItem);
items.add(exclusiveItem);
onDataLoaded(items);
}
@Override
public void onDestroy(){
super.onDestroy();
if(getMembersRequest!=null)
getMembersRequest.cancel();
}
@Override
protected void doLoadData(int offset, int count){}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
LinearLayout topView=new LinearLayout(getActivity());
topView.setOrientation(LinearLayout.VERTICAL);
titleEditLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_edit_text, topView, false);
titleEdit=titleEditLayout.findViewById(R.id.edit);
titleEdit.setHint(R.string.list_name);
titleEditLayout.updateHint();
if(followList!=null)
titleEdit.setText(followList.title);
topView.addView(titleEditLayout);
FloatingHintEditTextLayout showRepliesLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_spinner, topView, false);
showRepliesSpinner=showRepliesLayout.findViewById(R.id.spinner);
showRepliesLayout.setHint(R.string.list_show_replies_to);
topView.addView(showRepliesLayout);
ArrayAdapter<String> spinnerAdapter=new ArrayAdapter<>(getActivity(), R.layout.item_spinner, List.of(
getString(R.string.list_replies_no_one),
getString(R.string.list_replies_members),
getString(R.string.list_replies_anyone)
));
showRepliesSpinner.setAdapter(spinnerAdapter);
showRepliesSpinner.setSelection(switch(followList!=null ? followList.repliesPolicy : FollowList.RepliesPolicy.LIST){
case FOLLOWED -> 2;
case LIST -> 1;
case NONE -> 0;
});
ViewGroup.MarginLayoutParams llp=(ViewGroup.MarginLayoutParams)showRepliesLayout.getLabel().getLayoutParams();
llp.setMarginStart(llp.getMarginStart()+V.dp(16));
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SingleViewRecyclerAdapter(topView));
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected int indexOfItemsAdapter(){
return 1;
}
protected void doDeleteList(){
new DeleteList(followList.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
AccountSessionManager.get(accountID).getCacheController().deleteList(followList.id);
E.post(new ListDeletedEvent(accountID, followList.id));
Nav.finish(BaseEditListFragment.this);
}
@Override
public void onError(ErrorResponse error){
Activity activity=getActivity();
if(activity==null)
return;
error.showToast(activity);
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
private void onMembersClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
Nav.go(getActivity(), ListMembersFragment.class, args);
}
protected void loadMembers(){
getMembersRequest=new GetListAccounts(followList.id, null, 3)
.setCallback(new Callback<>(){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
getMembersRequest=null;
membersItem.avatars=new ArrayList<>();
for(int i=0;i<Math.min(3, result.size());i++){
Account acc=result.get(i);
membersItem.avatars.add(new UrlImageLoaderRequest(acc.avatarStatic, V.dp(32), V.dp(32)));
}
rebindItem(membersItem);
imgLoader.updateImages();
}
@Override
public void onError(ErrorResponse error){
getMembersRequest=null;
}
})
.exec(accountID);
}
protected FollowList.RepliesPolicy getSelectedRepliesPolicy(){
return switch(showRepliesSpinner.getSelectedItemPosition()){
case 0 -> FollowList.RepliesPolicy.NONE;
case 1 -> FollowList.RepliesPolicy.LIST;
case 2 -> FollowList.RepliesPolicy.FOLLOWED;
default -> throw new IllegalStateException("Unexpected value: "+showRepliesSpinner.getSelectedItemPosition());
};
}
}

View File

@@ -19,17 +19,14 @@ import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.SoundEffectConstants;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
@@ -49,7 +46,6 @@ import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.accounts.GetPreferences;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.EditStatus;
import org.joinmastodon.android.api.session.AccountSession;
@@ -57,7 +53,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.fragments.account_list.ComposeAccountSearchFragment;
import org.joinmastodon.android.fragments.account_list.AccountSearchFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
@@ -340,7 +336,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public void onLaunchAccountSearch(){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.goForResult(getActivity(), ComposeAccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this);
Nav.goForResult(getActivity(), AccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this);
}
});
View autocompleteView=autocompleteViewController.getView();
@@ -1016,8 +1012,26 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
return new String[]{"image/jpeg", "image/gif", "image/png", "video/mp4"};
}
private String sanitizeMediaDescription(String description){
if(description == null){
return null;
}
// The Gboard android keyboard attaches this text whenever the user
// pastes something from the keyboard's suggestion bar.
// Due to different end user locales, the exact text may vary, but at
// least in version 13.4.08, all of the translations contained the
// string "Gboard".
if (description.contains("Gboard")){
return null;
}
return description;
}
@Override
public boolean onAddMediaAttachmentFromEditText(Uri uri, String description){
description = sanitizeMediaDescription(description);
return mediaViewController.addMediaAttachment(uri, description);
}

View File

@@ -0,0 +1,323 @@
package org.joinmastodon.android.fragments;
import android.content.res.TypedArray;
import android.os.Bundle;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.view.WindowInsets;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.AddAccountsToList;
import org.joinmastodon.android.api.requests.lists.GetListAccounts;
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
import org.joinmastodon.android.events.FinishListCreationFragmentEvent;
import org.joinmastodon.android.fragments.account_list.AddNewListMembersFragment;
import org.joinmastodon.android.fragments.account_list.BaseAccountListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.joinmastodon.android.ui.views.CurlyArrowEmptyView;
import org.parceler.Parcels;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.fragments.WindowInsetsAwareFragment;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class CreateListAddMembersFragment extends BaseAccountListFragment implements OnBackPressedListener, AddNewListMembersFragment.Listener{
private FollowList followList;
private Button nextButton;
private View buttonBar;
private FragmentRootLinearLayout rootView;
private FrameLayout searchFragmentContainer;
private FrameLayout fragmentContentWrap;
private AddNewListMembersFragment searchFragment;
private WindowInsets lastInsets;
private boolean dismissingSearchFragment;
private HashSet<String> accountIDsInList=new HashSet<>();
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.manage_list_members);
setSubtitle(getString(R.string.step_x_of_y, 2, 2));
setLayout(R.layout.fragment_login);
setEmptyText(R.string.list_no_members);
setHasOptionsMenu(true);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
if(savedInstanceState!=null || getArguments().getBoolean("needLoadMembers", false)){
loadData();
}else{
onDataLoaded(List.of());
}
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetListAccounts(followList.id, null, 0)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
for(Account acc:result)
accountIDsInList.add(acc.id);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()));
}
})
.exec(accountID);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
View view=super.onCreateView(inflater, container, savedInstanceState);
FrameLayout wrapper=new FrameLayout(getActivity());
wrapper.addView(view);
rootView=(FragmentRootLinearLayout) view;
fragmentContentWrap=wrapper;
return wrapper;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
nextButton=view.findViewById(R.id.btn_next);
nextButton.setOnClickListener(this::onNextClick);
nextButton.setText(R.string.done);
buttonBar=view.findViewById(R.id.button_bar);
super.onViewCreated(view, savedInstanceState);
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
lastInsets=insets;
if(searchFragment!=null)
searchFragment.onApplyWindowInsets(insets);
insets=UiUtils.applyBottomInsetToFixedView(buttonBar, insets);
rootView.dispatchApplyWindowInsets(insets);
}
@Override
protected List<View> getViewsForElevationEffect(){
return List.of(getToolbar(), buttonBar);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
MenuItem item=menu.add(R.string.add_list_member);
item.setIcon(R.drawable.ic_add_24px);
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
if(searchFragmentContainer!=null)
return true;
searchFragmentContainer=new FrameLayout(getActivity());
searchFragmentContainer.setId(R.id.search_fragment);
fragmentContentWrap.addView(searchFragmentContainer);
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
args.putBoolean("_can_go_back", true);
searchFragment=new AddNewListMembersFragment(this);
searchFragment.setArguments(args);
getChildFragmentManager().beginTransaction().add(R.id.search_fragment, searchFragment).commit();
getChildFragmentManager().executePendingTransactions();
if(lastInsets!=null)
searchFragment.onApplyWindowInsets(lastInsets);
searchFragmentContainer.setTranslationX(V.dp(100));
searchFragmentContainer.setAlpha(0f);
searchFragmentContainer.animate().translationX(0).alpha(1).setDuration(300).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{
rootView.setVisibility(View.GONE);
}).start();
return true;
}
@Override
protected void initializeEmptyView(View contentView){
ViewStub emptyStub=contentView.findViewById(R.id.empty);
emptyStub.setLayoutResource(R.layout.empty_with_arrow);
super.initializeEmptyView(contentView);
TextView emptySecondary=contentView.findViewById(R.id.empty_text_secondary);
emptySecondary.setText(R.string.list_find_users);
CurlyArrowEmptyView arrowView=(CurlyArrowEmptyView) emptyView;
arrowView.setGravityAndOffsets(Gravity.TOP | Gravity.END, 24, 2);
}
@Override
protected void setStatusBarColor(int color){
rootView.setStatusBarColor(color);
}
@Override
protected void setNavigationBarColor(int color){
rootView.setNavigationBarColor(color);
}
private void dismissSearchFragment(){
if(searchFragment==null || dismissingSearchFragment)
return;
dismissingSearchFragment=true;
rootView.setVisibility(View.VISIBLE);
searchFragmentContainer.animate().translationX(V.dp(100)).alpha(0).setDuration(200).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{
getChildFragmentManager().beginTransaction().remove(searchFragment).commit();
getChildFragmentManager().executePendingTransactions();
fragmentContentWrap.removeView(searchFragmentContainer);
searchFragmentContainer=null;
searchFragment=null;
dismissingSearchFragment=false;
}).start();
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0);
}
private void onNextClick(View v){
E.post(new FinishListCreationFragmentEvent(accountID, followList.id));
Nav.finish(this);
}
@Override
public boolean onBackPressed(){
if(searchFragment!=null){
dismissSearchFragment();
return true;
}
return false;
}
@Override
public boolean isAccountInList(AccountViewModel account){
return accountIDsInList.contains(account.account.id);
}
@Override
public void addAccountToList(AccountViewModel account, Runnable onDone){
new AddAccountsToList(followList.id, Set.of(account.account.id))
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
accountIDsInList.add(account.account.id);
if(onDone!=null)
onDone.run();
int i=0;
for(AccountViewModel acc:data){
if(acc.account.id.equals(account.account.id)){
list.getAdapter().notifyItemChanged(i);
return;
}
i++;
}
int pos=data.size();
data.add(account);
list.getAdapter().notifyItemInserted(pos);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.exec(accountID);
}
@Override
public void removeAccountAccountFromList(AccountViewModel account, Runnable onDone){
new RemoveAccountsFromList(followList.id, Set.of(account.account.id))
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
accountIDsInList.remove(account.account.id);
if(onDone!=null)
onDone.run();
int i=0;
for(AccountViewModel acc:data){
if(acc.account.id.equals(account.account.id)){
list.getAdapter().notifyItemChanged(i);
return;
}
i++;
}
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.exec(accountID);
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
holder.setStyle(AccountViewHolder.AccessoryType.CUSTOM_BUTTON, false);
holder.setOnLongClickListener(vh->false);
Button button=holder.getButton();
button.setPadding(V.dp(24), 0, V.dp(24), 0);
button.setMinimumWidth(0);
button.setMinWidth(0);
button.setOnClickListener(v->{
holder.setActionProgressVisible(true);
holder.itemView.setHasTransientState(true);
Runnable onDone=()->{
holder.setActionProgressVisible(false);
holder.itemView.setHasTransientState(false);
};
AccountViewModel account=holder.getItem();
if(isAccountInList(account)){
removeAccountAccountFromList(account, onDone);
}else{
addAccountToList(account, onDone);
}
});
}
@Override
protected void onBindViewHolder(AccountViewHolder holder){
Button button=holder.getButton();
int textRes, styleRes;
if(isAccountInList(holder.getItem())){
textRes=R.string.remove;
styleRes=R.style.Widget_Mastodon_M3_Button_Tonal_Error;
}else{
textRes=R.string.add;
styleRes=R.style.Widget_Mastodon_M3_Button_Filled;
}
button.setText(textRes);
TypedArray ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background});
button.setBackground(ta.getDrawable(0));
ta.recycle();
ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor});
button.setTextColor(ta.getColorStateList(0));
ta.recycle();
}
@Override
protected void loadRelationships(List<AccountViewModel> accounts){
// no-op
}
}

View File

@@ -0,0 +1,149 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.view.WindowInsets;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.CreateList;
import org.joinmastodon.android.api.requests.lists.UpdateList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.FinishListCreationFragmentEvent;
import org.joinmastodon.android.events.ListCreatedEvent;
import org.joinmastodon.android.events.ListUpdatedEvent;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.List;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
public class CreateListFragment extends BaseEditListFragment{
private Button nextButton;
private View buttonBar;
private FollowList followList;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.create_list);
setSubtitle(getString(R.string.step_x_of_y, 1, 2));
setLayout(R.layout.fragment_login);
if(savedInstanceState!=null)
followList=Parcels.unwrap(savedInstanceState.getParcelable("list"));
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Override
protected int getNavigationIconDrawableResource(){
return R.drawable.ic_baseline_close_24;
}
@Override
public boolean wantsCustomNavigationIcon(){
return true;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
nextButton=view.findViewById(R.id.btn_next);
nextButton.setOnClickListener(this::onNextClick);
nextButton.setText(R.string.create);
buttonBar=view.findViewById(R.id.button_bar);
super.onViewCreated(view, savedInstanceState);
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets));
}
@Override
protected List<View> getViewsForElevationEffect(){
return List.of(getToolbar(), buttonBar);
}
@Override
public void onSaveInstanceState(Bundle outState){
super.onSaveInstanceState(outState);
outState.putParcelable("list", Parcels.wrap(followList));
}
private void onNextClick(View v){
String title=titleEdit.getText().toString().trim();
if(TextUtils.isEmpty(title)){
titleEditLayout.setErrorState(getString(R.string.required_form_field_blank));
return;
}
if(followList==null){
new CreateList(title, getSelectedRepliesPolicy(), exclusiveItem.checked)
.setCallback(new Callback<>(){
@Override
public void onSuccess(FollowList result){
followList=result;
proceed(false);
E.post(new ListCreatedEvent(accountID, result));
AccountSessionManager.get(accountID).getCacheController().addList(result);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}else if(!title.equals(followList.title) || getSelectedRepliesPolicy()!=followList.repliesPolicy || exclusiveItem.checked!=followList.exclusive){
new UpdateList(followList.id, title, getSelectedRepliesPolicy(), exclusiveItem.checked)
.setCallback(new Callback<>(){
@Override
public void onSuccess(FollowList result){
followList=result;
proceed(true);
E.post(new ListUpdatedEvent(accountID, result));
AccountSessionManager.get(accountID).getCacheController().updateList(result);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}else{
proceed(true);
}
}
private void proceed(boolean needLoadMembers){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
args.putBoolean("needLoadMembers", needLoadMembers);
Nav.go(getActivity(), CreateListAddMembersFragment.class, args);
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0);
}
@Subscribe
public void onFinishListCreationFragment(FinishListCreationFragmentEvent ev){
if(ev.accountID.equals(accountID) && followList!=null && ev.listID.equals(followList.id)){
Nav.finish(this);
}
}
}

View File

@@ -0,0 +1,67 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.UpdateList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.ListUpdatedEvent;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
public class EditListFragment extends BaseEditListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.edit_list);
loadMembers();
setHasOptionsMenu(true);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
menu.add(R.string.delete_list);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.delete_list)
.setMessage(getString(R.string.delete_list_confirm, followList.title))
.setPositiveButton(R.string.delete, (dlg, which)->doDeleteList())
.setNegativeButton(R.string.cancel, null)
.show();
return true;
}
@Override
public void onDestroy(){
super.onDestroy();
String newTitle=titleEdit.getText().toString();
FollowList.RepliesPolicy newRepliesPolicy=getSelectedRepliesPolicy();
boolean newExclusive=exclusiveItem.checked;
if(!newTitle.equals(followList.title) || newRepliesPolicy!=followList.repliesPolicy || newExclusive!=followList.exclusive){
new UpdateList(followList.id, newTitle, newRepliesPolicy, newExclusive)
.setCallback(new Callback<>(){
@Override
public void onSuccess(FollowList result){
AccountSessionManager.get(accountID).getCacheController().updateList(result);
E.post(new ListUpdatedEvent(accountID, result));
}
@Override
public void onError(ErrorResponse error){
// TODO handle errors somehow
}
})
.exec(accountID);
}
}
}

View File

@@ -18,6 +18,8 @@ import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.tags.GetTag;
import org.joinmastodon.android.api.requests.tags.SetTagFollowed;
import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.text.SpacerSpan;
@@ -47,6 +49,7 @@ public class HashtagTimelineFragment extends StatusListFragment{
private MenuItem followMenuItem;
private boolean followRequestRunning;
private boolean toolbarContentVisible;
private String maxID;
public HashtagTimelineFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
@@ -67,10 +70,13 @@ public class HashtagTimelineFragment extends StatusListFragment{
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetHashtagTimeline(hashtagName, offset==0 ? null : getMaxID(), null, count)
currentRequest=new GetHashtagTimeline(hashtagName, offset==0 ? null : maxID, null, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !result.isEmpty());
}
})
@@ -176,7 +182,7 @@ public class HashtagTimelineFragment extends StatusListFragment{
}
private void updateHeader(){
if(hashtag==null)
if(hashtag==null || getActivity()==null)
return;
if(hashtag.history!=null && !hashtag.history.isEmpty()){

View File

@@ -5,40 +5,50 @@ import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.AnimationUtils;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toolbar;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.viewcontrollers.HomeTimelineMenuController;
import org.joinmastodon.android.ui.viewcontrollers.ToolbarDropdownMenuController;
import org.joinmastodon.android.ui.views.FixedAspectRatioImageView;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.parceler.Parcels;
import java.util.Collections;
import java.util.HashSet;
@@ -52,44 +62,141 @@ import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class HomeTimelineFragment extends StatusListFragment{
public class HomeTimelineFragment extends StatusListFragment implements ToolbarDropdownMenuController.HostFragment{
private ImageButton fab;
private ImageView toolbarLogo;
private Button toolbarShowNewPostsBtn;
private LinearLayout listsDropdown;
private FixedAspectRatioImageView listsDropdownArrow;
private TextView listsDropdownText;
private Button newPostsBtn;
private View newPostsBtnWrap;
private boolean newPostsBtnShown;
private AnimatorSet currentNewPostsAnim;
private ToolbarDropdownMenuController dropdownController;
private HomeTimelineMenuController dropdownMainMenuController;
private List<FollowList> lists=List.of();
private ListMode listMode=ListMode.FOLLOWING;
private FollowList currentList;
private MergeRecyclerAdapter mergeAdapter;
private DiscoverInfoBannerHelper localTimelineBannerHelper;
private String maxID;
private String lastSavedMarkerID;
public HomeTimelineFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
setListLayoutId(R.layout.fragment_timeline);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
localTimelineBannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID);
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
dropdownController=new ToolbarDropdownMenuController(this);
dropdownMainMenuController=new HomeTimelineMenuController(dropdownController, new HomeTimelineMenuController.Callback(){
@Override
public void onFollowingSelected(){
if(listMode==ListMode.FOLLOWING)
return;
listMode=ListMode.FOLLOWING;
reload();
}
@Override
public void onLocalSelected(){
if(listMode==ListMode.LOCAL)
return;
listMode=ListMode.LOCAL;
reload();
}
@Override
public List<FollowList> getLists(){
return lists;
}
@Override
public void onListSelected(FollowList list){
if(listMode==ListMode.LIST && currentList==list)
return;
listMode=ListMode.LIST;
currentList=list;
reload();
}
});
setHasOptionsMenu(true);
loadData();
AccountSessionManager.get(accountID).getCacheController().getLists(new Callback<>(){
@Override
public void onSuccess(List<FollowList> result){
lists=result;
}
@Override
public void onError(ErrorResponse error){}
});
}
@Override
protected void doLoadData(int offset, int count){
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null)
return;
onDataLoaded(result.items, !result.items.isEmpty());
maxID=result.maxID;
if(result.isFromCache())
loadNewPosts();
}
});
switch(listMode){
case FOLLOWING -> {
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null || listMode!=ListMode.FOLLOWING)
return;
if(refreshing)
list.scrollToPosition(0);
onDataLoaded(result.items, !result.items.isEmpty());
maxID=result.maxID;
if(result.isFromCache())
loadNewPosts();
}
@Override
public void onError(ErrorResponse error){
if(listMode!=ListMode.FOLLOWING)
return;
super.onError(error);
}
});
}
case LOCAL -> {
currentRequest=new GetPublicTimeline(true, false, offset>0 ? maxID : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(refreshing)
list.scrollToPosition(0);
maxID=result.isEmpty() ? null : result.get(result.size()-1).id;
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
case LIST -> {
currentRequest=new GetListTimeline(currentList.id, offset>0 ? maxID : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(refreshing)
list.scrollToPosition(0);
maxID=result.isEmpty() ? null : result.get(result.size()-1).id;
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
}
}
@Override
@@ -97,6 +204,19 @@ public class HomeTimelineFragment extends StatusListFragment{
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
fab.setOnClickListener(this::onFabClick);
newPostsBtn=view.findViewById(R.id.new_posts_btn);
newPostsBtn.setOnClickListener(this::onNewPostsBtnClick);
newPostsBtnWrap=view.findViewById(R.id.new_posts_btn_wrap);
if(newPostsBtnShown){
newPostsBtnWrap.setVisibility(View.VISIBLE);
}else{
newPostsBtnWrap.setVisibility(View.GONE);
newPostsBtnWrap.setScaleX(0.9f);
newPostsBtnWrap.setScaleY(0.9f);
newPostsBtnWrap.setAlpha(0f);
newPostsBtnWrap.setTranslationY(V.dp(-56));
}
updateToolbarLogo();
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
@@ -116,13 +236,26 @@ public class HomeTimelineFragment extends StatusListFragment{
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.home, menu);
menu.findItem(R.id.edit_list).setVisible(listMode==ListMode.LIST);
GithubSelfUpdater.UpdateState state=GithubSelfUpdater.UpdateState.NO_UPDATE;
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
if(updater!=null)
state=updater.getState();
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_updateready_24px);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), SettingsMainFragment.class, args);
int id=item.getItemId();
if(id==R.id.settings){
Nav.go(getActivity(), SettingsMainFragment.class, args);
}else if(id==R.id.edit_list){
args.putParcelable("list", Parcels.wrap(currentList));
Nav.go(getActivity(), EditListFragment.class, args);
}
return true;
}
@@ -147,7 +280,7 @@ public class HomeTimelineFragment extends StatusListFragment{
@Override
protected void onHidden(){
super.onHidden();
if(!data.isEmpty()){
if(!data.isEmpty() && listMode==ListMode.FOLLOWING){
String topPostID=displayItems.get(Math.max(0, list.getChildAdapterPosition(list.getChildAt(0))-getMainAdapterOffset())).parentID;
if(!topPostID.equals(lastSavedMarkerID)){
lastSavedMarkerID=topPostID;
@@ -183,8 +316,8 @@ public class HomeTimelineFragment extends StatusListFragment{
// we'll get the currently topmost post as last in the response. This way we know there's no gap
// between the existing and newly loaded parts of the timeline.
String sinceID=data.size()>1 ? data.get(1).id : "1";
currentRequest=new GetHomeTimeline(null, null, 20, sinceID)
.setCallback(new Callback<>(){
boolean needCache=listMode==ListMode.FOLLOWING;
loadAdditionalPosts(null, null, 20, sinceID, new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
@@ -199,11 +332,13 @@ public class HomeTimelineFragment extends StatusListFragment{
result.get(result.size()-1).hasGapAfter=true;
toAdd=result;
}
AccountSessionManager.get(accountID).filterStatuses(toAdd, FilterContext.HOME);
if(needCache)
AccountSessionManager.get(accountID).filterStatuses(toAdd, FilterContext.HOME);
if(!toAdd.isEmpty()){
prependItems(toAdd, true);
showNewPostsButton();
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false);
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false);
}
}
@@ -212,8 +347,7 @@ public class HomeTimelineFragment extends StatusListFragment{
currentRequest=null;
dataLoading=false;
}
})
.exec(accountID);
});
}
@Override
@@ -225,10 +359,11 @@ public class HomeTimelineFragment extends StatusListFragment{
V.setVisibilityAnimated(item.text, View.GONE);
GapStatusDisplayItem gap=item.getItem();
dataLoading=true;
currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null)
.setCallback(new Callback<>(){
boolean needCache=listMode==ListMode.FOLLOWING;
loadAdditionalPosts(item.getItemID(), null, 20, null, new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
if(getActivity()==null)
@@ -242,7 +377,8 @@ public class HomeTimelineFragment extends StatusListFragment{
Status gapStatus=getStatusByID(gap.parentID);
if(gapStatus!=null){
gapStatus.hasGapAfter=false;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
}
}else{
Set<String> idsBelowGap=new HashSet<>();
@@ -254,7 +390,8 @@ public class HomeTimelineFragment extends StatusListFragment{
}else if(s.id.equals(gap.parentID)){
belowGap=true;
s.hasGapAfter=false;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false);
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false);
}else{
gapPostIndex++;
}
@@ -270,7 +407,8 @@ public class HomeTimelineFragment extends StatusListFragment{
}else{
result=result.subList(0, endIndex);
}
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
if(needCache)
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
@@ -287,7 +425,8 @@ public class HomeTimelineFragment extends StatusListFragment{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
}
}
@@ -304,9 +443,17 @@ public class HomeTimelineFragment extends StatusListFragment{
adapter.notifyItemChanged(gapPos);
}
}
})
.exec(accountID);
});
}
private void loadAdditionalPosts(String maxID, String minID, int limit, String sinceID, Callback<List<Status>> callback){
MastodonAPIRequest<List<Status>> req=switch(listMode){
case FOLLOWING -> new GetHomeTimeline(maxID, minID, limit, sinceID);
case LOCAL -> new GetPublicTimeline(true, false, maxID, minID, limit, sinceID);
case LIST -> new GetListTimeline(currentList.id, maxID, minID, limit, sinceID);
};
currentRequest=req;
req.setCallback(callback).exec(accountID);
}
@Override
@@ -320,42 +467,41 @@ public class HomeTimelineFragment extends StatusListFragment{
}
private void updateToolbarLogo(){
toolbarLogo=new ImageView(getActivity());
toolbarLogo.setScaleType(ImageView.ScaleType.CENTER);
toolbarLogo.setImageResource(R.drawable.logo);
toolbarLogo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)));
toolbarShowNewPostsBtn=new Button(getActivity());
toolbarShowNewPostsBtn.setTextAppearance(R.style.m3_title_medium);
toolbarShowNewPostsBtn.setTextColor(0xffffffff);
toolbarShowNewPostsBtn.setStateListAnimator(null);
toolbarShowNewPostsBtn.setBackgroundResource(R.drawable.bg_button_new_posts);
toolbarShowNewPostsBtn.setText(R.string.see_new_posts);
toolbarShowNewPostsBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_fluent_arrow_up_16_filled, 0, 0, 0);
toolbarShowNewPostsBtn.setCompoundDrawableTintList(toolbarShowNewPostsBtn.getTextColors());
toolbarShowNewPostsBtn.setCompoundDrawablePadding(V.dp(8));
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N)
UiUtils.fixCompoundDrawableTintOnAndroid6(toolbarShowNewPostsBtn);
toolbarShowNewPostsBtn.setOnClickListener(this::onNewPostsBtnClick);
if(newPostsBtnShown){
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
toolbarLogo.setVisibility(View.INVISIBLE);
toolbarLogo.setAlpha(0f);
}else{
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
toolbarShowNewPostsBtn.setAlpha(0f);
toolbarShowNewPostsBtn.setScaleX(.8f);
toolbarShowNewPostsBtn.setScaleY(.8f);
toolbarLogo.setVisibility(View.VISIBLE);
}
listsDropdown=new LinearLayout(getActivity());
listsDropdown.setOnClickListener(this::onListsDropdownClick);
listsDropdown.setBackgroundResource(R.drawable.bg_button_m3_text);
listsDropdown.setAccessibilityDelegate(new View.AccessibilityDelegate(){
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){
super.onInitializeAccessibilityNodeInfo(host, info);
info.setClassName("android.widget.Spinner");
}
});
listsDropdownArrow=new FixedAspectRatioImageView(getActivity());
listsDropdownArrow.setUseHeight(true);
listsDropdownArrow.setImageResource(R.drawable.ic_arrow_drop_down_24px);
listsDropdownArrow.setScaleType(ImageView.ScaleType.CENTER);
listsDropdownArrow.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
listsDropdown.addView(listsDropdownArrow, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
listsDropdownText=new TextView(getActivity());
listsDropdownText.setTextAppearance(R.style.action_bar_title);
listsDropdownText.setSingleLine();
listsDropdownText.setEllipsize(TextUtils.TruncateAt.END);
listsDropdownText.setGravity(Gravity.START | Gravity.CENTER_VERTICAL);
listsDropdownText.setPaddingRelative(V.dp(4), 0, V.dp(16), 0);
listsDropdownText.setText(getCurrentListTitle());
listsDropdownArrow.setImageTintList(listsDropdownText.getTextColors());
listsDropdown.setBackgroundTintList(listsDropdownText.getTextColors());
listsDropdown.addView(listsDropdownText, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
FrameLayout logoWrap=new FrameLayout(getActivity());
logoWrap.addView(toolbarLogo, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER));
logoWrap.addView(toolbarShowNewPostsBtn, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, V.dp(32), Gravity.CENTER));
FrameLayout.LayoutParams ddlp=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.START);
ddlp.topMargin=ddlp.bottomMargin=V.dp(8);
logoWrap.addView(listsDropdown, ddlp);
Toolbar toolbar=getToolbar();
toolbar.addView(logoWrap, new Toolbar.LayoutParams(Gravity.CENTER));
toolbar.addView(logoWrap, new Toolbar.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
toolbar.setContentInsetsRelative(V.dp(16), 0);
}
private void showNewPostsButton(){
@@ -365,20 +511,19 @@ public class HomeTimelineFragment extends StatusListFragment{
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
newPostsBtnWrap.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(toolbarLogo, View.ALPHA, 0f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, 1f)
ObjectAnimator.ofFloat(newPostsBtnWrap, View.ALPHA, 1f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_Y, 1f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.TRANSLATION_Y, 0f)
);
set.setDuration(300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.setDuration(getResources().getInteger(R.integer.m3_sys_motion_duration_medium3));
set.setInterpolator(AnimationUtils.loadInterpolator(getActivity(), R.interpolator.m3_sys_motion_easing_standard_decelerate));
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
toolbarLogo.setVisibility(View.INVISIBLE);
currentNewPostsAnim=null;
}
});
@@ -393,20 +538,19 @@ public class HomeTimelineFragment extends StatusListFragment{
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
toolbarLogo.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(toolbarLogo, View.ALPHA, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 0f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, .8f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, .8f)
ObjectAnimator.ofFloat(newPostsBtnWrap, View.ALPHA, 0f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_X, .9f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_Y, .9f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.TRANSLATION_Y, V.dp(-56))
);
set.setDuration(300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.setDuration(getResources().getInteger(R.integer.m3_sys_motion_duration_medium3));
set.setInterpolator(AnimationUtils.loadInterpolator(getActivity(), R.interpolator.m3_sys_motion_easing_standard_accelerate));
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
newPostsBtnWrap.setVisibility(View.GONE);
currentNewPostsAnim=null;
}
});
@@ -421,6 +565,20 @@ public class HomeTimelineFragment extends StatusListFragment{
}
}
private void onListsDropdownClick(View v){
listsDropdownArrow.animate().rotation(-180f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
dropdownController.show(dropdownMainMenuController);
AccountSessionManager.get(accountID).getCacheController().reloadLists(new Callback<>(){
@Override
public void onSuccess(java.util.List<FollowList> result){
lists=result;
}
@Override
public void onError(ErrorResponse error){}
});
}
@Override
public void onDestroyView(){
super.onDestroyView();
@@ -443,4 +601,67 @@ public class HomeTimelineFragment extends StatusListFragment{
protected boolean shouldRemoveAccountPostsWhenUnfollowing(){
return true;
}
@Override
public Toolbar getToolbar(){
return super.getToolbar();
}
@Override
public void onDropdownWillDismiss(){
listsDropdownArrow.animate().rotation(0f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
@Override
public void onDropdownDismissed(){
}
@Override
public void reload(){
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
refreshing=true;
showProgress();
loadData();
listsDropdownText.setText(getCurrentListTitle());
invalidateOptionsMenu();
}
@Override
protected RecyclerView.Adapter getAdapter(){
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
}
@Override
protected void onDataLoaded(List<Status> d, boolean more){
if(refreshing){
if(listMode==ListMode.LOCAL){
localTimelineBannerHelper.maybeAddBanner(list, mergeAdapter);
localTimelineBannerHelper.onBannerBecameVisible();
}else{
localTimelineBannerHelper.removeBanner(mergeAdapter);
}
}
super.onDataLoaded(d, more);
}
private String getCurrentListTitle(){
return switch(listMode){
case FOLLOWING -> getString(R.string.timeline_following);
case LOCAL -> getString(R.string.local_timeline);
case LIST -> currentList.title;
};
}
private enum ListMode{
FOLLOWING,
LOCAL,
LIST
}
}

View File

@@ -0,0 +1,301 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.WindowInsets;
import android.widget.ImageButton;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.lists.AddAccountsToList;
import org.joinmastodon.android.api.requests.lists.GetListAccounts;
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
import org.joinmastodon.android.events.AccountAddedToListEvent;
import org.joinmastodon.android.events.AccountRemovedFromListEvent;
import org.joinmastodon.android.fragments.account_list.AddListMembersFragment;
import org.joinmastodon.android.fragments.account_list.PaginatedAccountListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.ActionModeHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.parceler.Parcels;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.utils.V;
public class ListMembersFragment extends PaginatedAccountListFragment{
private static final int ADD_MEMBER_RESULT=600;
private ImageButton fab;
private FollowList followList;
private boolean inSelectionMode;
private Set<String> selectedAccounts=new HashSet<>();
private ActionMode actionMode;
private MenuItem deleteItem;
public ListMembersFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
setTitle(R.string.list_members);
setHasOptionsMenu(true);
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetListAccounts(followList.id, maxID, count);
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
super.onConfigureViewHolder(holder);
holder.setStyle(inSelectionMode ? AccountViewHolder.AccessoryType.CHECKBOX : AccountViewHolder.AccessoryType.MENU, false);
holder.setOnClickListener(this::onItemClick);
holder.setOnLongClickListener(this::onItemLongClick);
holder.getContextMenu().getMenu().add(0, R.id.remove_from_list, 0, R.string.remove_from_list);
holder.setOnCustomMenuItemSelectedListener(item->onItemMenuItemSelected(holder, item));
}
@Override
protected void onBindViewHolder(AccountViewHolder holder){
super.onBindViewHolder(holder);
holder.setStyle(inSelectionMode ? AccountViewHolder.AccessoryType.CHECKBOX : AccountViewHolder.AccessoryType.MENU, false);
if(inSelectionMode){
holder.setChecked(selectedAccounts.contains(holder.getItem().account.id));
}
}
@Override
public boolean wantsLightStatusBar(){
if(actionMode!=null)
return UiUtils.isDarkTheme();
return super.wantsLightStatusBar();
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.selectable_list, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
int id=item.getItemId();
if(id==R.id.select){
enterSelectionMode();
}else if(id==R.id.select_all){
for(AccountViewModel a:data){
selectedAccounts.add(a.account.id);
}
enterSelectionMode();
}
return true;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
fab.setImageResource(R.drawable.ic_add_24px);
fab.setContentDescription(getString(R.string.add_list_member));
fab.setOnClickListener(v->onFabClick());
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
super.onApplyWindowInsets(insets);
UiUtils.applyBottomInsetToFAB(fab, insets);
}
@Override
public void onFragmentResult(int reqCode, boolean success, Bundle result){
if(reqCode==ADD_MEMBER_RESULT && success){
Account acc=Objects.requireNonNull(Parcels.unwrap(result.getParcelable("selectedAccount")));
addAccounts(List.of(acc));
}
}
@Subscribe
public void onAccountRemovedFromList(AccountRemovedFromListEvent ev){
if(ev.accountID.equals(accountID) && ev.listID.equals(followList.id)){
removeAccountRows(Set.of(ev.targetAccountID));
}
}
@Subscribe
public void onAccountAddedToList(AccountAddedToListEvent ev){
if(ev.accountID.equals(accountID) && ev.listID.equals(followList.id)){
data.add(new AccountViewModel(ev.account, accountID));
list.getAdapter().notifyItemInserted(data.size()-1);
}
}
private void onFabClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.goForResult(getActivity(), AddListMembersFragment.class, args, ADD_MEMBER_RESULT, this);
}
private void onItemClick(AccountViewHolder holder){
if(inSelectionMode){
String id=holder.getItem().account.id;
if(selectedAccounts.contains(id)){
selectedAccounts.remove(id);
holder.setChecked(false);
}else{
selectedAccounts.add(id);
holder.setChecked(true);
}
updateActionModeTitle();
deleteItem.setEnabled(!selectedAccounts.isEmpty());
return;
}
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(holder.getItem().account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
private boolean onItemLongClick(AccountViewHolder holder){
if(inSelectionMode)
return false;
selectedAccounts.add(holder.getItem().account.id);
enterSelectionMode();
return true;
}
private void onItemMenuItemSelected(AccountViewHolder holder, MenuItem item){
int id=item.getItemId();
if(id==R.id.remove_from_list){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.confirm_remove_list_member)
.setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(Set.of(holder.getItem().account.id)))
.setNegativeButton(R.string.cancel, null)
.show();
}
}
private void updateItemsForSelectionModeTransition(){
list.getAdapter().notifyItemRangeChanged(0, data.size());
}
private void enterSelectionMode(){
inSelectionMode=true;
updateItemsForSelectionModeTransition();
V.setVisibilityAnimated(fab, View.INVISIBLE);
actionMode=ActionModeHelper.startActionMode(this, ()->elevationOnScrollListener.getCurrentStatusBarColor(), new ActionMode.Callback(){
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu){
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu){
mode.getMenuInflater().inflate(R.menu.settings_filter_words_action_mode, menu);
deleteItem=menu.findItem(R.id.delete);
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.confirm_remove_list_members)
.setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(new HashSet<>(selectedAccounts)))
.setNegativeButton(R.string.cancel, null)
.show();
return true;
}
@Override
public void onDestroyActionMode(ActionMode mode){
actionMode=null;
inSelectionMode=false;
selectedAccounts.clear();
updateItemsForSelectionModeTransition();
V.setVisibilityAnimated(fab, View.VISIBLE);
}
});
updateActionModeTitle();
}
private void updateActionModeTitle(){
actionMode.setTitle(getResources().getQuantityString(R.plurals.x_items_selected, selectedAccounts.size(), selectedAccounts.size()));
}
private void removeAccounts(Set<String> ids){
new RemoveAccountsFromList(followList.id, ids)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
if(inSelectionMode)
actionMode.finish();
removeAccountRows(ids);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
private void addAccounts(Collection<Account> accounts){
new AddAccountsToList(followList.id, accounts.stream().map(a->a.id).collect(Collectors.toSet()))
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
for(Account acc:accounts){
data.add(new AccountViewModel(acc, accountID));
}
list.getAdapter().notifyItemRangeInserted(data.size()-accounts.size(), accounts.size());
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
private void removeAccountRows(Set<String> ids){
for(int i=data.size()-1;i>=0;i--){
if(ids.contains(data.get(i).account.id)){
data.remove(i);
list.getAdapter().notifyItemRemoved(i);
}
}
}
}

View File

@@ -0,0 +1,61 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Status;
import org.parceler.Parcels;
import java.util.List;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
public class ListTimelineFragment extends StatusListFragment{
private FollowList followList;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
setTitle(followList.title);
setHasOptionsMenu(true);
loadData();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetListTimeline(followList.id, offset>0 ? getMaxID() : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.standalone_list_timeline, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
int id=item.getItemId();
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
if(id==R.id.members){
Nav.go(getActivity(), ListMembersFragment.class, args);
}else if(id==R.id.edit_list){
Nav.go(getActivity(), EditListFragment.class, args);
}
return true;
}
}

View File

@@ -0,0 +1,95 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.tags.GetFollowedTags;
import org.joinmastodon.android.api.requests.tags.SetTagFollowed;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.stream.Collectors;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
public class ManageFollowedHashtagsFragment extends BaseSettingsFragment<Hashtag> implements ListItemWithOptionsMenu.OptionsMenuListener<Hashtag>{
private String maxID;
public ManageFollowedHashtagsFragment(){
super(100);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.manage_hashtags);
loadData();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetFollowedTags(offset>0 ? maxID : null, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Hashtag> result){
maxID=null;
if(result.nextPageUri!=null)
maxID=result.nextPageUri.getQueryParameter("max_id");
onDataLoaded(result.stream().map(t->{
int posts=t.getWeekPosts();
return new ListItemWithOptionsMenu<>(t.name, getResources().getQuantityString(R.plurals.x_posts_recently, posts, posts), ManageFollowedHashtagsFragment.this,
R.drawable.ic_tag_24px, ManageFollowedHashtagsFragment.this::onItemClick, t, false);
}).collect(Collectors.toList()), maxID!=null);
}
})
.exec(accountID);
}
@Override
public void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu<Hashtag> item, Menu menu){
menu.clear();
menu.add(getString(R.string.unfollow_user, "#"+item.parentObject.name));
}
@Override
public void onListItemOptionSelected(ListItemWithOptionsMenu<Hashtag> item, MenuItem menuItem){
new M3AlertDialogBuilder(getActivity())
.setTitle(getString(R.string.unfollow_confirmation, "#"+item.parentObject.name))
.setPositiveButton(R.string.unfollow, (dlg, which)->doUnfollow(item))
.setNegativeButton(R.string.cancel, null)
.show();
}
private void onItemClick(ListItemWithOptionsMenu<Hashtag> item){
UiUtils.openHashtagTimeline(getActivity(), accountID, item.parentObject);
}
private void doUnfollow(ListItemWithOptionsMenu<Hashtag> item){
new SetTagFollowed(item.parentObject.name, false)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Hashtag result){
int index=data.indexOf(item);
if(index==-1)
return;
data.remove(index);
list.getAdapter().notifyItemRemoved(index);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
}

View File

@@ -0,0 +1,199 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.WindowInsets;
import android.widget.ImageButton;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.DeleteList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.ListCreatedEvent;
import org.joinmastodon.android.events.ListDeletedEvent;
import org.joinmastodon.android.events.ListUpdatedEvent;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
public class ManageListsFragment extends BaseSettingsFragment<FollowList> implements ListItemWithOptionsMenu.OptionsMenuListener<FollowList>{
private ImageButton fab;
public ManageListsFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.manage_lists);
loadData();
setRefreshEnabled(true);
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Override
protected void doLoadData(int offset, int count){
Callback<List<FollowList>> callback=new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowList> result){
onDataLoaded(result.stream().map(ManageListsFragment.this::makeItem).collect(Collectors.toList()), false);
}
};
if(refreshing){
AccountSessionManager.get(accountID)
.getCacheController()
.reloadLists(callback);
}else{
AccountSessionManager.get(accountID)
.getCacheController()
.getLists(callback);
}
}
private ListItem<FollowList> makeItem(FollowList l){
return new ListItemWithOptionsMenu<>(l.title, null, ManageListsFragment.this, R.drawable.ic_list_alt_24px, ManageListsFragment.this::onListClick, l, false);
}
private void onListClick(ListItemWithOptionsMenu<FollowList> item){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(item.parentObject));
Nav.go(getActivity(), ListTimelineFragment.class, args);
}
@Override
public void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu<FollowList> item, Menu menu){
menu.add(0, R.id.edit, 0, R.string.edit_list);
menu.add(0, R.id.delete, 1, R.string.delete_list);
}
@Override
public void onListItemOptionSelected(ListItemWithOptionsMenu<FollowList> item, MenuItem menuItem){
int id=menuItem.getItemId();
if(id==R.id.edit){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(item.parentObject));
Nav.go(getActivity(), EditListFragment.class, args);
}else if(id==R.id.delete){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.delete_list)
.setMessage(getString(R.string.delete_list_confirm, item.parentObject.title))
.setPositiveButton(R.string.delete, (dlg, which)->doDeleteList(item.parentObject))
.setNegativeButton(R.string.cancel, null)
.show();
}
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
fab.setImageResource(R.drawable.ic_add_24px);
fab.setContentDescription(getString(R.string.create_list));
fab.setOnClickListener(v->onFabClick());
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
super.onApplyWindowInsets(insets);
UiUtils.applyBottomInsetToFAB(fab, insets);
}
private void doDeleteList(FollowList list){
new DeleteList(list.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
for(int i=0;i<data.size();i++){
if(data.get(i).parentObject==list){
data.remove(i);
itemsAdapter.notifyItemRemoved(i);
AccountSessionManager.get(accountID).getCacheController().deleteList(list.id);
break;
}
}
}
@Override
public void onError(ErrorResponse error){
Activity activity=getActivity();
if(activity==null)
return;
error.showToast(activity);
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
@Subscribe
public void onListUpdated(ListUpdatedEvent ev){
if(!ev.accountID.equals(accountID))
return;
for(ListItem<FollowList> item:data){
if(item.parentObject.id.equals(ev.list.id)){
item.parentObject=ev.list;
item.title=ev.list.title;
rebindItem(item);
break;
}
}
}
@Subscribe
public void onListDeleted(ListDeletedEvent ev){
if(!ev.accountID.equals(accountID))
return;
int i=0;
for(ListItem<FollowList> item:data){
if(item.parentObject.id.equals(ev.listID)){
data.remove(i);
itemsAdapter.notifyItemRemoved(i);
break;
}
i++;
}
}
@Subscribe
public void onListCreated(ListCreatedEvent ev){
if(!ev.accountID.equals(accountID))
return;
ListItem<FollowList> item=makeItem(ev.list);
data.add(item);
((List<ListItem<FollowList>>)data).sort(Comparator.comparing(l->l.parentObject.title));
itemsAdapter.notifyItemInserted(data.indexOf(item));
}
private void onFabClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), CreateListFragment.class, args);
}
}

View File

@@ -5,6 +5,7 @@ import android.view.View;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ElevationOnScrollListener;
@@ -35,8 +36,15 @@ public abstract class MastodonRecyclerFragment<T> extends BaseRecyclerFragment<T
@CallSuper
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
if(wantsElevationOnScrollEffect())
list.addOnScrollListener(elevationOnScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, getViewsForElevationEffect()));
if(wantsElevationOnScrollEffect()){
FragmentRootLinearLayout rootView;
if(view instanceof FragmentRootLinearLayout frl)
rootView=frl;
else
rootView=view.findViewById(R.id.appkit_loader_root);
list.addOnScrollListener(elevationOnScrollListener=new ElevationOnScrollListener(rootView, getViewsForElevationEffect()));
}
list.setItemAnimator(new BetterItemAnimator());
if(refreshLayout!=null){
int colorBackground=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background);
int colorPrimary=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary);

View File

@@ -44,7 +44,7 @@ import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
public class NotificationsListFragment extends BaseStatusListFragment<Notification>{
private boolean onlyMentions=true;
private boolean onlyMentions;
private String maxID;
private View tabBar;
private View mentionsTab, allTab;
@@ -58,9 +58,7 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
super.onCreate(savedInstanceState);
setLayout(R.layout.fragment_notifications);
E.register(this);
if(savedInstanceState!=null){
onlyMentions=savedInstanceState.getBoolean("onlyMentions", true);
}
onlyMentions=AccountSessionManager.get(accountID).isNotificationsMentionsOnly();
setHasOptionsMenu(true);
}
@@ -132,13 +130,9 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
protected void onShown(){
super.onShown();
unreadMarker=realUnreadMarker=AccountSessionManager.get(accountID).getLastKnownNotificationsMarker();
if(!dataLoading){
if(onlyMentions){
refresh();
}else{
reloadingFromCache=true;
refresh();
}
if(!dataLoading && canRefreshWithoutUpsettingUser()){
reloadingFromCache=true;
refresh();
}
}
@@ -221,12 +215,6 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
return views;
}
@Override
public void onSaveInstanceState(Bundle outState){
super.onSaveInstanceState(outState);
outState.putBoolean("onlyMentions", onlyMentions);
}
private Notification getNotificationByID(String id){
for(Notification n:data){
if(n.id.equals(id))
@@ -291,8 +279,10 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
allTab.setSelected(!onlyMentions);
maxID=null;
showProgress();
loadData(0, 20);
refreshing=true;
reloadingFromCache=true;
loadData(0, 20);
AccountSessionManager.get(accountID).setNotificationsMentionsOnly(onlyMentions);
}
@Override
@@ -312,7 +302,6 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.notifications, menu);
markAllReadItem=menu.findItem(R.id.mark_all_read);
updateMarkAllReadButton();
}
@Override
@@ -330,7 +319,6 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
new SaveMarkers(null, id).exec(accountID);
AccountSessionManager.get(accountID).setNotificationsMarker(id, true);
realUnreadMarker=id;
updateMarkAllReadButton();
}
}
@@ -348,10 +336,6 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
});
}
private void updateMarkAllReadButton(){
markAllReadItem.setEnabled(!data.isEmpty() && realUnreadMarker!=null && !realUnreadMarker.equals(data.get(0).id));
}
@Override
public void onAppendItems(List<Notification> items){
super.onAppendItems(items);
@@ -364,4 +348,20 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
}
}
}
private boolean canRefreshWithoutUpsettingUser(){
// TODO maybe reload notifications the same way we reload the home timelines, i.e. with gaps and stuff
if(data.size()<=itemsPerPage)
return true;
for(int i=list.getChildCount()-1;i>=0;i--){
if(list.getChildViewHolder(list.getChildAt(i)) instanceof StatusDisplayItem.Holder<?> itemHolder){
String id=itemHolder.getItemID();
for(int j=0;j<data.size();j++){
if(data.get(j).id.equals(id))
return j<itemsPerPage; // Can refresh the list without losing scroll position if it is within the first page
}
}
}
return true;
}
}

View File

@@ -301,9 +301,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
username+="@"+AccountSessionManager.getInstance().getAccount(accountID).domain;
}
getActivity().getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, "@"+username));
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.TIRAMISU || UiUtils.isMIUI()){ // Android 13+ SystemUI shows its own thing when you put things into the clipboard
Toast.makeText(getActivity(), R.string.text_copied, Toast.LENGTH_SHORT).show();
}
UiUtils.maybeShowTextCopiedToast(getActivity());
return true;
});
@@ -609,6 +607,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
menu.findItem(R.id.block_domain).setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
else
menu.findItem(R.id.block_domain).setVisible(false);
menu.findItem(R.id.add_to_list).setVisible(relationship.following);
}
@Override
@@ -662,6 +661,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
}else if(id==R.id.save){
if(isInEditMode)
saveAndExitEditMode();
}else if(id==R.id.add_to_list){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("targetAccount", Parcels.wrap(account));
Nav.go(getActivity(), AddAccountToListsFragment.class, args);
}
return true;
}
@@ -850,6 +854,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
aboutFragment.enterEditMode(account.source.fields);
refreshLayout.setEnabled(false);
editDirty=false;
V.setVisibilityAnimated(fab, View.GONE);
}
private void exitEditMode(){
@@ -892,6 +897,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
refreshLayout.setEnabled(true);
bindHeaderView();
V.setVisibilityAnimated(fab, View.VISIBLE);
}
private void saveAndExitEditMode(){

View File

@@ -47,13 +47,12 @@ public class SplashFragment extends AppKitFragment{
private ProgressBarButton defaultServerButton;
private ProgressBar defaultServerProgress;
private String chosenDefaultServer=DEFAULT_SERVER;
private boolean loadingDefaultServer;
private boolean loadingDefaultServer, loadedDefaultServer;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
motionEffect=new InterpolatingMotionEffect(MastodonApp.context);
loadAndChooseDefaultServer();
}
@Nullable
@@ -101,6 +100,8 @@ public class SplashFragment extends AppKitFragment{
});
}
});
if(!loadedDefaultServer && !loadingDefaultServer)
loadAndChooseDefaultServer();
return contentView;
}
@@ -198,6 +199,8 @@ public class SplashFragment extends AppKitFragment{
private void loadAndChooseDefaultServer(){
loadingDefaultServer=true;
defaultServerButton.setTextVisible(false);
defaultServerProgress.setVisibility(View.VISIBLE);
new GetCatalogDefaultInstances()
.setCallback(new Callback<>(){
@Override
@@ -239,6 +242,7 @@ public class SplashFragment extends AppKitFragment{
private void setChosenDefaultServer(String domain){
chosenDefaultServer=domain;
loadingDefaultServer=false;
loadedDefaultServer=true;
if(defaultServerButton!=null && getActivity()!=null){
defaultServerButton.setTextVisible(true);
defaultServerProgress.setVisibility(View.GONE);

View File

@@ -5,7 +5,9 @@ import android.text.TextUtils;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.SearchViewHelper;
@@ -13,13 +15,14 @@ import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.parceler.Parcels;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
public class ComposeAccountSearchFragment extends BaseAccountListFragment{
private String currentQuery;
public class AccountSearchFragment extends BaseAccountListFragment{
protected String currentQuery;
private boolean resultDelivered;
private SearchViewHelper searchViewHelper;
@@ -28,12 +31,11 @@ public class ComposeAccountSearchFragment extends BaseAccountListFragment{
super.onCreate(savedInstanceState);
setRefreshEnabled(false);
setEmptyText("");
dataLoaded();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getString(R.string.search_hint));
searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getSearchViewPlaceholder());
searchViewHelper.setListeners(this::onQueryChanged, null);
searchViewHelper.addDivider(contentView);
super.onViewCreated(view, savedInstanceState);
@@ -51,13 +53,21 @@ public class ComposeAccountSearchFragment extends BaseAccountListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(SearchResults result){
setEmptyText(R.string.no_search_results);
onDataLoaded(result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false);
AccountSearchFragment.this.onSuccess(result.accounts);
}
})
.exec(accountID);
}
protected void onSuccess(List<Account> result){
setEmptyText(R.string.no_search_results);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false);
}
protected String getSearchViewPlaceholder(){
return getString(R.string.search_hint);
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();

View File

@@ -0,0 +1,37 @@
package org.joinmastodon.android.fragments.account_list;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.SearchAccounts;
import org.joinmastodon.android.model.Account;
import java.util.List;
import me.grishka.appkit.api.SimpleCallback;
public class AddListMembersFragment extends AccountSearchFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
dataLoaded();
}
@Override
protected void doLoadData(int offset, int count){
refreshing=true;
currentRequest=new SearchAccounts(currentQuery, 0, 0, false, true)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Account> result){
AddListMembersFragment.this.onSuccess(result);
}
})
.exec(accountID);
}
@Override
protected String getSearchViewPlaceholder(){
return getString(R.string.search_among_people_you_follow);
}
}

View File

@@ -0,0 +1,125 @@
package org.joinmastodon.android.fragments.account_list;
import android.annotation.SuppressLint;
import android.content.res.TypedArray;
import android.os.Bundle;
import android.text.TextUtils;
import android.widget.Button;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountFollowing;
import org.joinmastodon.android.api.requests.accounts.SearchAccounts;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.V;
@SuppressLint("ValidFragment") // This shouldn't be part of any saved states anyway
public class AddNewListMembersFragment extends AccountSearchFragment{
private Listener listener;
private String maxID;
public AddNewListMembersFragment(Listener listener){
this.listener=listener;
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
loadData();
}
@Override
protected void doLoadData(int offset, int count){
if(TextUtils.isEmpty(currentQuery)){
currentRequest=new GetAccountFollowing(AccountSessionManager.get(accountID).self.id, offset>0 ? maxID : null, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
setEmptyText("");
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), result.nextPageUri!=null);
maxID=result.getNextPageMaxID();
}
})
.exec(accountID);
}else{
refreshing=true;
currentRequest=new SearchAccounts(currentQuery, 0, 0, false, true)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Account> result){
AddNewListMembersFragment.this.onSuccess(result);
}
})
.exec(accountID);
}
}
@Override
protected String getSearchViewPlaceholder(){
return getString(R.string.search_among_people_you_follow);
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
holder.setStyle(AccountViewHolder.AccessoryType.CUSTOM_BUTTON, false);
holder.setOnLongClickListener(vh->false);
Button button=holder.getButton();
button.setPadding(V.dp(24), 0, V.dp(24), 0);
button.setMinimumWidth(0);
button.setMinWidth(0);
button.setOnClickListener(v->{
holder.setActionProgressVisible(true);
holder.itemView.setHasTransientState(true);
Runnable onDone=()->{
holder.setActionProgressVisible(false);
holder.itemView.setHasTransientState(false);
onBindViewHolder(holder);
};
AccountViewModel account=holder.getItem();
if(listener.isAccountInList(account)){
listener.removeAccountAccountFromList(account, onDone);
}else{
listener.addAccountToList(account, onDone);
}
});
}
@Override
protected void onBindViewHolder(AccountViewHolder holder){
Button button=holder.getButton();
int textRes, styleRes;
if(listener.isAccountInList(holder.getItem())){
textRes=R.string.remove;
styleRes=R.style.Widget_Mastodon_M3_Button_Tonal_Error;
}else{
textRes=R.string.add;
styleRes=R.style.Widget_Mastodon_M3_Button_Filled;
}
button.setText(textRes);
TypedArray ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background});
button.setBackground(ta.getDrawable(0));
ta.recycle();
ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor});
button.setTextColor(ta.getColorStateList(0));
ta.recycle();
}
@Override
protected void loadRelationships(List<AccountViewModel> accounts){
// no-op
}
public interface Listener{
boolean isAccountInList(AccountViewModel account);
void addAccountToList(AccountViewModel account, Runnable onDone);
void removeAccountAccountFromList(AccountViewModel account, Runnable onDone);
}
}

View File

@@ -73,6 +73,8 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<A
protected void loadRelationships(List<AccountViewModel> accounts){
Set<String> ids=accounts.stream().map(ai->ai.account.id).collect(Collectors.toSet());
if(ids.isEmpty())
return;
GetAccountRelationships req=new GetAccountRelationships(ids);
relationshipsRequests.add(req);
req.setCallback(new Callback<>(){
@@ -122,20 +124,9 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<A
Toolbar toolbar=getToolbar();
if(toolbar!=null && toolbar.getNavigationIcon()!=null){
toolbar.setNavigationContentDescription(R.string.back);
if(hasSubtitle()){
toolbar.setTitleTextAppearance(getActivity(), R.style.m3_title_medium);
toolbar.setSubtitleTextAppearance(getActivity(), R.style.m3_body_medium);
int color=UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary);
toolbar.setTitleTextColor(color);
toolbar.setSubtitleTextColor(color);
}
}
}
protected boolean hasSubtitle(){
return true;
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
@@ -150,6 +141,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<A
}
protected void onConfigureViewHolder(AccountViewHolder holder){}
protected void onBindViewHolder(AccountViewHolder holder){}
protected class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
@@ -167,6 +159,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<A
@Override
public void onBindViewHolder(AccountViewHolder holder, int position){
holder.bind(data.get(position));
BaseAccountListFragment.this.onBindViewHolder(holder);
super.onBindViewHolder(holder, position);
}

View File

@@ -14,8 +14,4 @@ public abstract class StatusRelatedAccountListFragment extends PaginatedAccountL
status=Parcels.unwrap(getArguments().getParcelable("status"));
}
@Override
protected boolean hasSubtitle(){
return false;
}
}

View File

@@ -49,7 +49,6 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
private DiscoverNewsFragment newsFragment;
private DiscoverAccountsFragment accountsFragment;
private SearchFragment searchFragment;
private LocalTimelineFragment localTimelineFragment;
private String accountID;
private String currentQuery;
@@ -71,15 +70,14 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
tabLayout=view.findViewById(R.id.tabbar);
pager=view.findViewById(R.id.pager);
tabViews=new FrameLayout[5];
tabViews=new FrameLayout[4];
for(int i=0;i<tabViews.length;i++){
FrameLayout tabView=new FrameLayout(getActivity());
tabView.setId(switch(i){
case 0 -> R.id.discover_posts;
case 1 -> R.id.discover_hashtags;
case 2 -> R.id.discover_news;
case 3 -> R.id.discover_local_timeline;
case 4 -> R.id.discover_users;
case 3 -> R.id.discover_users;
default -> throw new IllegalStateException("Unexpected value: "+i);
});
tabView.setVisibility(View.GONE);
@@ -122,12 +120,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
accountsFragment=new DiscoverAccountsFragment();
accountsFragment.setArguments(args);
localTimelineFragment=new LocalTimelineFragment();
localTimelineFragment.setArguments(args);
getChildFragmentManager().beginTransaction()
.add(R.id.discover_posts, postsFragment)
.add(R.id.discover_local_timeline, localTimelineFragment)
.add(R.id.discover_hashtags, hashtagsFragment)
.add(R.id.discover_news, newsFragment)
.add(R.id.discover_users, accountsFragment)
@@ -141,8 +135,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
case 0 -> R.string.posts;
case 1 -> R.string.hashtags;
case 2 -> R.string.news;
case 3 -> R.string.local_timeline;
case 4 -> R.string.for_you;
case 3 -> R.string.for_you;
default -> throw new IllegalStateException("Unexpected value: "+position);
});
}
@@ -245,8 +238,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
case 0 -> postsFragment;
case 1 -> hashtagsFragment;
case 2 -> newsFragment;
case 3 -> localTimelineFragment;
case 4 -> accountsFragment;
case 3 -> accountsFragment;
default -> throw new IllegalStateException("Unexpected value: "+page);
};
}

View File

@@ -3,7 +3,9 @@ package org.joinmastodon.android.fragments.discover;
import android.os.Bundle;
import org.joinmastodon.android.api.requests.trends.GetTrendingStatuses;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
@@ -15,6 +17,7 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class DiscoverPostsFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper;
private int realOffset=0;
@Override
public void onCreate(Bundle savedInstanceState){
@@ -24,10 +27,12 @@ public class DiscoverPostsFragment extends StatusListFragment{
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetTrendingStatuses(offset, count)
currentRequest=new GetTrendingStatuses(offset==0 ? 0 : realOffset, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
realOffset+=result.size();
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !result.isEmpty());
bannerHelper.onBannerBecameVisible();
}

View File

@@ -1,54 +0,0 @@
package org.joinmastodon.android.fragments.discover;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class LocalTimelineFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper;
private String maxID;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
boolean empty=result.isEmpty();
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !empty);
bannerHelper.onBannerBecameVisible();
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
}

View File

@@ -32,6 +32,7 @@ import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
public class SearchFragment extends BaseStatusListFragment<SearchResult>{
private String currentQuery;
@@ -137,7 +138,7 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
}*/
int offset=_offset;
currentRequest=new GetSearchResults(currentQuery, type, type==null, maxID, offset, type==null ? 0 : count)
.setCallback(new Callback<>(){
.setCallback(new SimpleCallback<SearchResults>(this){
@Override
public void onSuccess(SearchResults result){
ArrayList<SearchResult> results=new ArrayList<>();
@@ -158,16 +159,10 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
}
prevDisplayItems=new ArrayList<>(displayItems);
unfilteredResults=results;
boolean wasRefreshing=refreshing;
onDataLoaded(filterSearchResults(results), type!=null && !results.isEmpty());
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
Activity a=getActivity();
if(a==null)
return;
error.showToast(a);
if(wasRefreshing)
list.scrollToPosition(0);
}
})
.exec(accountID);

View File

@@ -112,7 +112,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
onDataLoaded(results.stream().map(sr->{
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, true);
if(sr.type==SearchResult.Type.HASHTAG){
vm.hashtagItem.onClick=()->openHashtag(sr);
vm.hashtagItem.setOnClick(i->openHashtag(sr));
}
return vm;
}).collect(Collectors.toList()), false);
@@ -129,7 +129,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
.map(sr->{
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, false);
if(sr.type==SearchResult.Type.HASHTAG){
vm.hashtagItem.onClick=()->openHashtag(sr);
vm.hashtagItem.setOnClick(i->openHashtag(sr));
}
return vm;
})
@@ -389,18 +389,18 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
deliverResult(currentQuery, null);
}
private void onOpenURLClick(){
private void onOpenURLClick(ListItem<?> item_){
((MainActivity)getActivity()).handleURL(Uri.parse(searchViewHelper.getQuery()), accountID);
}
private void onGoToHashtagClick(){
private void onGoToHashtagClick(ListItem<?> item_){
String q=searchViewHelper.getQuery();
if(q.startsWith("#"))
q=q.substring(1);
UiUtils.openHashtagTimeline(getActivity(), accountID, q);
}
private void onGoToAccountClick(){
private void onGoToAccountClick(ListItem<?> item_){
String q=searchViewHelper.getQuery();
if(!q.startsWith("@")){
q="@"+q;
@@ -411,11 +411,11 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
((MainActivity)getActivity()).openSearchQuery(q, accountID, R.string.loading, true);
}
private void onGoToStatusSearchClick(){
private void onGoToStatusSearchClick(ListItem<?> item_){
deliverResult(searchViewHelper.getQuery(), SearchResult.Type.STATUS);
}
private void onGoToAccountSearchClick(){
private void onGoToAccountSearchClick(ListItem<?> item_){
deliverResult(searchViewHelper.getQuery(), SearchResult.Type.ACCOUNT);
}

View File

@@ -165,9 +165,7 @@ public class AccountActivationFragment extends ToolbarFragment{
private void tryGetAccount(){
if(AccountSessionManager.getInstance().tryGetAccount(accountID)==null){
uiHandler.removeCallbacks(pollRunnable);
getActivity().finish();
Intent intent=new Intent(getActivity(), MainActivity.class);
startActivity(intent);
((MainActivity)getActivity()).restartHomeFragment();
return;
}
currentRequest=new GetOwnAccount()

View File

@@ -12,12 +12,10 @@ import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
import org.joinmastodon.android.ui.viewholders.CheckableListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.V;
public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<ListItem<T>>{
protected GenericListItemsAdapter<T> itemsAdapter;
@@ -45,7 +43,7 @@ public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<L
@Override
protected RecyclerView.Adapter<?> getAdapter(){
return itemsAdapter=new GenericListItemsAdapter<T>(data);
return itemsAdapter=new GenericListItemsAdapter<T>(imgLoader, data);
}
@Override
@@ -59,12 +57,13 @@ public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<L
return 0;
}
protected void toggleCheckableItem(CheckableListItem<T> item){
item.toggle();
protected void toggleCheckableItem(ListItem<?> item){
if(item instanceof CheckableListItem<?> checkable)
checkable.toggle();
rebindItem(item);
}
protected void rebindItem(ListItem<T> item){
protected void rebindItem(ListItem<?> item){
if(list==null)
return;
if(list.findViewHolderForAdapterPosition(indexOfItemsAdapter()+data.indexOf(item)) instanceof ListItemViewHolder<?> holder){

View File

@@ -73,7 +73,7 @@ public class EditFilterFragment extends BaseSettingsFragment<Void> implements On
durationItem=new ListItem<>(R.string.settings_filter_duration, 0, this::onDurationClick),
wordsItem=new ListItem<>(R.string.settings_filter_muted_words, 0, this::onWordsClick),
contextItem=new ListItem<>(R.string.settings_filter_context, 0, this::onContextClick),
cwItem=new CheckableListItem<>(R.string.settings_filter_show_cw, R.string.settings_filter_show_cw_explanation, CheckableListItem.Style.SWITCH, filter==null || filter.filterAction==FilterAction.WARN, ()->toggleCheckableItem(cwItem))
cwItem=new CheckableListItem<>(R.string.settings_filter_show_cw, R.string.settings_filter_show_cw_explanation, CheckableListItem.Style.SWITCH, filter==null || filter.filterAction==FilterAction.WARN, this::toggleCheckableItem)
));
if(filter!=null){
@@ -113,7 +113,7 @@ public class EditFilterFragment extends BaseSettingsFragment<Void> implements On
return 1;
}
private void onDurationClick(){
private void onDurationClick(ListItem<Void> item_){
int[] durationOptions={
1800,
3600,
@@ -182,21 +182,21 @@ public class EditFilterFragment extends BaseSettingsFragment<Void> implements On
alert.setOnDismissListener(dialog->callback.accept(null));
}
private void onWordsClick(){
private void onWordsClick(ListItem<Void> item){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelableArrayList("words", (ArrayList<? extends Parcelable>) keywords.stream().map(Parcels::wrap).collect(Collectors.toCollection(ArrayList::new)));
Nav.goForResult(getActivity(), FilterWordsFragment.class, args, WORDS_RESULT, this);
}
private void onContextClick(){
private void onContextClick(ListItem<Void> item){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putSerializable("context", context);
Nav.goForResult(getActivity(), FilterContextFragment.class, args, CONTEXT_RESULT, this);
}
private void onDeleteClick(){
private void onDeleteClick(ListItem<Void> item_){
AlertDialog alert=new M3AlertDialogBuilder(getActivity())
.setTitle(getString(R.string.settings_delete_filter_title, filter.title))
.setMessage(R.string.settings_delete_filter_confirmation)

View File

@@ -22,10 +22,9 @@ public class FilterContextFragment extends BaseSettingsFragment<FilterContext> i
setTitle(R.string.settings_filter_context);
context=(EnumSet<FilterContext>) getArguments().getSerializable("context");
onDataLoaded(Arrays.stream(FilterContext.values()).map(c->{
CheckableListItem<FilterContext> item=new CheckableListItem<>(c.getDisplayNameRes(), 0, CheckableListItem.Style.CHECKBOX, context.contains(c), null);
CheckableListItem<FilterContext> item=new CheckableListItem<>(c.getDisplayNameRes(), 0, CheckableListItem.Style.CHECKBOX, context.contains(c), this::toggleCheckableItem);
item.parentObject=c;
item.isEnabled=true;
item.onClick=()->toggleCheckableItem(item);
return item;
}).collect(Collectors.toList()));
}

View File

@@ -1,11 +1,6 @@
package org.joinmastodon.android.fragments.settings;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.IntEvaluator;
import android.animation.ObjectAnimator;
import android.app.AlertDialog;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcelable;
@@ -27,6 +22,7 @@ import org.joinmastodon.android.model.FilterKeyword;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.ActionModeHelper;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout;
@@ -37,7 +33,6 @@ import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.utils.V;
@@ -60,7 +55,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
FilterKeyword word=Parcels.unwrap(p);
ListItem<FilterKeyword> item=new ListItem<>(word.keyword, null, null, word);
item.isEnabled=true;
item.onClick=()->onWordClick(item);
item.setOnClick(this::onWordClick);
return item;
}).collect(Collectors.toList()));
setHasOptionsMenu(true);
@@ -114,7 +109,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.settings_filter_words, menu);
inflater.inflate(R.menu.selectable_list, menu);
}
@Override
@@ -174,7 +169,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
w.keyword=input;
ListItem<FilterKeyword> item=new ListItem<>(w.keyword, null, null, w);
item.isEnabled=true;
item.onClick=()->onWordClick(item);
item.setOnClick(this::onWordClick);
data.add(item);
itemsAdapter.notifyItemInserted(data.size()-1);
}else{
@@ -228,29 +223,15 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
return;
V.setVisibilityAnimated(fab, View.GONE);
actionMode=getActivity().startActionMode(new ActionMode.Callback(){
actionMode=ActionModeHelper.startActionMode(this, ()->elevationOnScrollListener.getCurrentStatusBarColor(), new ActionMode.Callback(){
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu){
ObjectAnimator anim=ObjectAnimator.ofInt(getActivity().getWindow(), "statusBarColor", elevationOnScrollListener.getCurrentStatusBarColor(), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary));
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.start();
((FragmentStackActivity) getActivity()).invalidateSystemBarColors(FilterWordsFragment.this);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu){
mode.getMenuInflater().inflate(R.menu.settings_filter_words_action_mode, menu);
for(int i=0;i<menu.size();i++){
Drawable icon=menu.getItem(i).getIcon().mutate();
icon.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnPrimary));
menu.getItem(i).setIcon(icon);
}
deleteItem=menu.findItem(R.id.delete);
return true;
}
@@ -266,21 +247,6 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
@Override
public void onDestroyActionMode(ActionMode mode){
leaveSelectionMode(true);
ObjectAnimator anim=ObjectAnimator.ofInt(getActivity().getWindow(), "statusBarColor", UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary), elevationOnScrollListener.getCurrentStatusBarColor());
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
getActivity().getWindow().setStatusBarColor(0);
}
});
anim.start();
((FragmentStackActivity) getActivity()).invalidateSystemBarColors(FilterWordsFragment.this);
}
});
@@ -289,7 +255,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
ListItem<FilterKeyword> item=data.get(i);
CheckableListItem<FilterKeyword> newItem=new CheckableListItem<>(item.title, null, CheckableListItem.Style.CHECKBOX, selectAll, null);
newItem.isEnabled=true;
newItem.onClick=()->onSelectionModeWordClick(newItem);
newItem.setOnClick(this::onSelectionModeWordClick);
newItem.parentObject=item.parentObject;
if(selectAll)
selectedItems.add(newItem);
@@ -313,7 +279,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
ListItem<FilterKeyword> item=data.get(i);
ListItem<FilterKeyword> newItem=new ListItem<>(item.title, null, null);
newItem.isEnabled=true;
newItem.onClick=()->onWordClick(newItem);
newItem.setOnClick(this::onWordClick);
newItem.parentObject=item.parentObject;
data.set(i, newItem);
}

View File

@@ -32,10 +32,10 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment<Void>{
setTitle(getString(R.string.about_app, getString(R.string.app_name)));
AccountSession s=AccountSessionManager.get(accountID);
onDataLoaded(List.of(
new ListItem<>(R.string.settings_even_more, 0, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/auth/edit")),
new ListItem<>(R.string.settings_contribute, 0, ()->UiUtils.launchWebBrowser(getActivity(), getString(R.string.github_url))),
new ListItem<>(R.string.settings_tos, 0, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/terms")),
new ListItem<>(R.string.settings_privacy_policy, 0, ()->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true),
new ListItem<>(R.string.settings_even_more, 0, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/auth/edit")),
new ListItem<>(R.string.settings_contribute, 0, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.github_url))),
new ListItem<>(R.string.settings_tos, 0, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/terms")),
new ListItem<>(R.string.settings_privacy_policy, 0, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true),
mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick)
));
@@ -62,7 +62,7 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment<Void>{
return adapter;
}
private void onClearMediaCacheClick(){
private void onClearMediaCacheClick(ListItem<?> item){
MastodonAPIController.runInBackground(()->{
Activity activity=getActivity();
ImageCache.getInstance(getActivity()).clear();

View File

@@ -33,19 +33,19 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment<Void>{
onDataLoaded(List.of(
languageItem=new ListItem<>(getString(R.string.default_post_language), postLanguage!=null ? postLanguage.getDisplayName(Locale.getDefault()) : null, R.drawable.ic_language_24px, this::onDefaultLanguageClick),
altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_alt_24px, ()->toggleCheckableItem(altTextItem)),
playGifsItem=new CheckableListItem<>(R.string.settings_gif, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_animation_24px, ()->toggleCheckableItem(playGifsItem)),
customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_open_in_browser_24px, ()->toggleCheckableItem(customTabsItem)),
confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_person_remove_24px, ()->toggleCheckableItem(confirmUnfollowItem)),
confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_repeat_24px, ()->toggleCheckableItem(confirmBoostItem)),
confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_delete_24px, ()->toggleCheckableItem(confirmDeleteItem))
altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_alt_24px, this::toggleCheckableItem),
playGifsItem=new CheckableListItem<>(R.string.settings_gif, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_animation_24px, this::toggleCheckableItem),
customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_open_in_browser_24px, this::toggleCheckableItem),
confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_person_remove_24px, this::toggleCheckableItem),
confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_repeat_24px, this::toggleCheckableItem),
confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_delete_24px, this::toggleCheckableItem)
));
}
@Override
protected void doLoadData(int offset, int count){}
private void onDefaultLanguageClick(){
private void onDefaultLanguageClick(ListItem<?> item){
ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), null, new ComposeLanguageAlertViewController.SelectedOption(-1, postLanguage), null);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.default_post_language)

View File

@@ -39,7 +39,7 @@ public class SettingsDebugFragment extends BaseSettingsFragment<Void>{
@Override
protected void doLoadData(int offset, int count){}
private void onTestEmailConfirmClick(){
private void onTestEmailConfirmClick(ListItem<?> item){
AccountSession sess=AccountSessionManager.getInstance().getAccount(accountID);
sess.activated=false;
sess.activationInfo=new AccountActivationInfo("test@email", System.currentTimeMillis());
@@ -49,18 +49,18 @@ public class SettingsDebugFragment extends BaseSettingsFragment<Void>{
Nav.goClearingStack(getActivity(), AccountActivationFragment.class, args);
}
private void onForceSelfUpdateClick(){
private void onForceSelfUpdateClick(ListItem<?> item){
GithubSelfUpdater.forceUpdate=true;
GithubSelfUpdater.getInstance().maybeCheckForUpdates();
restartUI();
}
private void onResetUpdaterClick(){
private void onResetUpdaterClick(ListItem<?> item){
GithubSelfUpdater.getInstance().reset();
restartUI();
}
private void onResetDiscoverBannersClick(){
private void onResetDiscoverBannersClick(ListItem<?> item){
DiscoverInfoBannerHelper.reset();
restartUI();
}

View File

@@ -39,10 +39,10 @@ public class SettingsDisplayFragment extends BaseSettingsFragment<Void>{
AccountLocalPreferences lp=s.getLocalPreferences();
onDataLoaded(List.of(
themeItem=new ListItem<>(R.string.settings_theme, getAppearanceValue(), R.drawable.ic_dark_mode_24px, this::onAppearanceClick),
showCWsItem=new CheckableListItem<>(R.string.settings_show_cws, 0, CheckableListItem.Style.SWITCH, lp.showCWs, R.drawable.ic_warning_24px, ()->toggleCheckableItem(showCWsItem)),
hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_no_adult_content_24px, ()->toggleCheckableItem(hideSensitiveMediaItem)),
interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, 0, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_social_leaderboard_24px, ()->toggleCheckableItem(interactionCountsItem)),
emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_emoticon_24px, ()->toggleCheckableItem(emojiInNamesItem))
showCWsItem=new CheckableListItem<>(R.string.settings_show_cws, 0, CheckableListItem.Style.SWITCH, lp.showCWs, R.drawable.ic_warning_24px, this::toggleCheckableItem),
hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_no_adult_content_24px, this::toggleCheckableItem),
interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, 0, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_social_leaderboard_24px, this::toggleCheckableItem),
emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_emoticon_24px, this::toggleCheckableItem)
));
}
@@ -80,7 +80,7 @@ public class SettingsDisplayFragment extends BaseSettingsFragment<Void>{
};
}
private void onAppearanceClick(){
private void onAppearanceClick(ListItem<?> item_){
int selected=switch(GlobalUserPreferences.theme){
case LIGHT -> 0;
case DARK -> 1;

View File

@@ -67,16 +67,14 @@ public class SettingsFiltersFragment extends BaseSettingsFragment<Filter>{
Nav.go(getActivity(), EditFilterFragment.class, args);
}
private void onAddFilterClick(){
private void onAddFilterClick(ListItem<?> item){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), EditFilterFragment.class, args);
}
private ListItem<Filter> makeListItem(Filter f){
ListItem<Filter> item=new ListItem<>(f.title, getString(f.isActive() ? R.string.filter_active : R.string.filter_inactive), null, f);
item.onClick=()->onFilterClick(item);
item.isEnabled=true;
ListItem<Filter> item=new ListItem<>(f.title, getString(f.isActive() ? R.string.filter_active : R.string.filter_inactive), this::onFilterClick, f);
return item;
}

View File

@@ -17,6 +17,7 @@ import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -57,11 +58,12 @@ public class SettingsMainFragment extends BaseSettingsFragment<Void>{
new ListItem<>(R.string.settings_notifications, 0, R.drawable.ic_notifications_24px, this::onNotificationsClick),
new ListItem<>(AccountSessionManager.get(accountID).domain, getString(R.string.settings_server_explanation), R.drawable.ic_dns_24px, this::onServerClick),
new ListItem<>(getString(R.string.about_app, getString(R.string.app_name)), null, R.drawable.ic_info_24px, this::onAboutClick, null, 0, true),
new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_switch_account_24px, this::onManageAccountsClick),
new ListItem<>(R.string.log_out, 0, R.drawable.ic_logout_24px, this::onLogOutClick, R.attr.colorM3Error, false)
));
if(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")){
data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, ()->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true));
data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true));
}
AccountSession session=AccountSessionManager.get(accountID);
@@ -122,43 +124,45 @@ public class SettingsMainFragment extends BaseSettingsFragment<Void>{
return args;
}
private void onBehaviorClick(){
private void onBehaviorClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsBehaviorFragment.class, makeFragmentArgs());
}
private void onDisplayClick(){
private void onDisplayClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsDisplayFragment.class, makeFragmentArgs());
}
private void onPrivacyClick(){
private void onPrivacyClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsPrivacyFragment.class, makeFragmentArgs());
}
private void onFiltersClick(){
private void onFiltersClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsFiltersFragment.class, makeFragmentArgs());
}
private void onNotificationsClick(){
private void onNotificationsClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsNotificationsFragment.class, makeFragmentArgs());
}
private void onServerClick(){
private void onServerClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsServerFragment.class, makeFragmentArgs());
}
private void onAboutClick(){
private void onAboutClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsAboutAppFragment.class, makeFragmentArgs());
}
private void onLogOutClick(){
private void onManageAccountsClick(ListItem<?> item){
new AccountSwitcherSheet(getActivity(), null).show();
}
private void onLogOutClick(ListItem<?> item_){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new M3AlertDialogBuilder(getActivity())
.setMessage(getString(R.string.confirm_log_out, session.getFullUsername()))
.setPositiveButton(R.string.log_out, (dialog, which)->AccountSessionManager.get(accountID).logOut(getActivity(), ()->{
loggedOut=true;
getActivity().finish();
Intent intent=new Intent(getActivity(), MainActivity.class);
startActivity(intent);
((MainActivity)getActivity()).restartHomeFragment();
}))
.setNegativeButton(R.string.cancel, null)
.show();

View File

@@ -55,14 +55,14 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
getPushSubscription();
onDataLoaded(List.of(
pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_notifications_paused_24px, ()->onPauseNotificationsClick(false)),
pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_notifications_paused_24px, i->onPauseNotificationsClick(false)),
policyItem=new ListItem<>(R.string.settings_notifications_policy, 0, R.drawable.ic_group_24px, this::onNotificationsPolicyClick),
mentionsItem=new CheckableListItem<>(R.string.notification_type_mentions_and_replies, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.mention, ()->toggleCheckableItem(mentionsItem)),
boostsItem=new CheckableListItem<>(R.string.notification_type_reblog, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.reblog, ()->toggleCheckableItem(boostsItem)),
favoritesItem=new CheckableListItem<>(R.string.notification_type_favorite, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.favourite, ()->toggleCheckableItem(favoritesItem)),
followersItem=new CheckableListItem<>(R.string.notification_type_follow, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.follow, ()->toggleCheckableItem(followersItem)),
pollsItem=new CheckableListItem<>(R.string.notification_type_poll, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.poll, ()->toggleCheckableItem(pollsItem))
mentionsItem=new CheckableListItem<>(R.string.notification_type_mentions_and_replies, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.mention, this::toggleCheckableItem),
boostsItem=new CheckableListItem<>(R.string.notification_type_reblog, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.reblog, this::toggleCheckableItem),
favoritesItem=new CheckableListItem<>(R.string.notification_type_favorite, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.favourite, this::toggleCheckableItem),
followersItem=new CheckableListItem<>(R.string.notification_type_follow, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.follow, this::toggleCheckableItem),
pollsItem=new CheckableListItem<>(R.string.notification_type_poll, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.poll, this::toggleCheckableItem)
));
typeItems=List.of(mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem);
@@ -209,7 +209,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
alert.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
}
private void onNotificationsPolicyClick(){
private void onNotificationsPolicyClick(ListItem<?> item_){
String[] items=Stream.of(
R.string.notifications_policy_anyone,
R.string.notifications_policy_followed,

View File

@@ -18,8 +18,8 @@ public class SettingsPrivacyFragment extends BaseSettingsFragment<Void>{
setTitle(R.string.settings_privacy);
Account self=AccountSessionManager.get(accountID).self;
onDataLoaded(List.of(
discoverableItem=new CheckableListItem<>(R.string.settings_discoverable, 0, CheckableListItem.Style.SWITCH, self.discoverable, R.drawable.ic_thumbs_up_down_24px, ()->toggleCheckableItem(discoverableItem)),
indexableItem=new CheckableListItem<>(R.string.settings_indexable, 0, CheckableListItem.Style.SWITCH, self.source.indexable!=null ? self.source.indexable : true, R.drawable.ic_search_24px, ()->toggleCheckableItem(indexableItem))
discoverableItem=new CheckableListItem<>(R.string.settings_discoverable, 0, CheckableListItem.Style.SWITCH, self.discoverable, R.drawable.ic_thumbs_up_down_24px, this::toggleCheckableItem),
indexableItem=new CheckableListItem<>(R.string.settings_indexable, 0, CheckableListItem.Style.SWITCH, self.source.indexable!=null ? self.source.indexable : true, R.drawable.ic_search_24px, this::toggleCheckableItem)
));
if(self.source.indexable==null)
indexableItem.isEnabled=false;

View File

@@ -139,7 +139,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{
if(!TextUtils.isEmpty(instance.email)){
needDivider=true;
SimpleListItemViewHolder holder=new SimpleListItemViewHolder(getActivity(), scrollingLayout);
ListItem<Void> item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_mail_24px, ()->{});
ListItem<Void> item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_mail_24px, i->{});
holder.bind(item);
holder.itemView.setBackground(UiUtils.getThemeDrawable(getActivity(), android.R.attr.selectableItemBackground));
holder.itemView.setOnClickListener(v->openAdminEmail());

View File

@@ -45,26 +45,34 @@ public class Attachment extends BaseModel{
public int getWidth(){
if(meta==null)
return 0;
return 1920;
if(meta.width>0)
return meta.width;
if(meta.original!=null && meta.original.width>0)
return meta.original.width;
if(meta.small!=null && meta.small.width>0)
return meta.small.width;
return 0;
return 1920;
}
public int getHeight(){
if(meta==null)
return 0;
return 1080;
if(meta.height>0)
return meta.height;
if(meta.original!=null && meta.original.height>0)
return meta.original.height;
if(meta.small!=null && meta.small.height>0)
return meta.small.height;
return 0;
return 1080;
}
public boolean hasKnownDimensions(){
return meta!=null && (
(meta.height>0 && meta.width>0)
|| (meta.original!=null && meta.original.height>0 && meta.original.width>0)
|| (meta.small!=null && meta.small.height>0 && meta.small.width>0)
);
}
public double getDuration(){

View File

@@ -0,0 +1,43 @@
package org.joinmastodon.android.model;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.ObjectValidationException;
import org.parceler.Parcel;
// Called like this to avoid conflict with java.util.List
@AllFieldsAreRequired
@Parcel
public class FollowList extends BaseModel{
public String id;
public String title;
public RepliesPolicy repliesPolicy=RepliesPolicy.LIST;
public boolean exclusive;
@Override
public String toString(){
return "FollowList{"+
"id='"+id+'\''+
", title='"+title+'\''+
", repliesPolicy="+repliesPolicy+
", exclusive="+exclusive+
'}';
}
@Override
public void postprocess() throws ObjectValidationException{
if(repliesPolicy==null)
repliesPolicy=RepliesPolicy.LIST;
super.postprocess();
}
public enum RepliesPolicy{
@SerializedName("followed")
FOLLOWED,
@SerializedName("list")
LIST,
@SerializedName("none")
NONE
}
}

View File

@@ -45,4 +45,8 @@ public class Hashtag extends BaseModel implements DisplayItemsParent{
public int hashCode(){
return name.hashCode();
}
public int getWeekPosts(){
return history.stream().mapToInt(h->h.uses).sum();
}
}

View File

@@ -21,4 +21,10 @@ public class HeaderPaginationList<T> extends ArrayList<T>{
public HeaderPaginationList(@NonNull Collection<? extends T> c){
super(c);
}
public String getNextPageMaxID(){
if(nextPageUri==null)
return null;
return nextPageUri.getQueryParameter("max_id");
}
}

View File

@@ -31,7 +31,7 @@ public class Status extends BaseModel implements DisplayItemsParent{
public StatusPrivacy visibility;
public boolean sensitive;
@RequiredField
public String spoilerText;
public String spoilerText="";
@RequiredField
public List<Attachment> mediaAttachments;
public Application application;

View File

@@ -0,0 +1,22 @@
package org.joinmastodon.android.model.viewmodel;
import org.joinmastodon.android.R;
import java.util.List;
import java.util.function.Consumer;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
public class AvatarPileListItem<T> extends ListItem<T>{
public List<ImageLoaderRequest> avatars;
public AvatarPileListItem(String title, String subtitle, List<ImageLoaderRequest> avatars, int iconRes, Consumer<AvatarPileListItem<T>> onClick, T parentObject, boolean dividerAfter){
super(title, subtitle, iconRes, (Consumer<ListItem<T>>)(Object)onClick, parentObject, 0, dividerAfter);
this.avatars=avatars;
}
@Override
public int getItemViewType(){
return R.id.list_item_avatar_pile;
}
}

View File

@@ -9,42 +9,42 @@ public class CheckableListItem<T> extends ListItem<T>{
public boolean checked;
public Consumer<Boolean> checkedChangeListener;
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Runnable onClick, T parentObject, boolean dividerAfter){
super(title, subtitle, iconRes, onClick, parentObject, 0, dividerAfter);
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick, T parentObject, boolean dividerAfter){
super(title, subtitle, iconRes, (Consumer<ListItem<T>>)(Object)onClick, parentObject, 0, dividerAfter);
this.style=style;
this.checked=checked;
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Runnable onClick){
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Consumer<CheckableListItem<T>> onClick){
this(title, subtitle, style, checked, 0, onClick, null, false);
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Runnable onClick, T parentObject){
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Consumer<CheckableListItem<T>> onClick, T parentObject){
this(title, subtitle, style, checked, 0, onClick, parentObject, false);
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Runnable onClick){
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick){
this(title, subtitle, style, checked, iconRes, onClick, null, false);
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Runnable onClick, T parentObject){
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick, T parentObject){
this(title, subtitle, style, checked, iconRes, onClick, parentObject, false);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Runnable onClick){
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Consumer<CheckableListItem<T>> onClick){
this(titleRes, subtitleRes, style, checked, 0, onClick, false);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Runnable onClick, boolean dividerAfter){
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Consumer<CheckableListItem<T>> onClick, boolean dividerAfter){
this(titleRes, subtitleRes, style, checked, 0, onClick, dividerAfter);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Runnable onClick){
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick){
this(titleRes, subtitleRes, style, checked, iconRes, onClick, false);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Runnable onClick, boolean dividerAfter){
super(titleRes, subtitleRes, iconRes, onClick, 0, dividerAfter);
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick, boolean dividerAfter){
super(titleRes, subtitleRes, iconRes, (Consumer<ListItem<T>>)(Object)onClick, 0, dividerAfter);
this.style=style;
this.checked=checked;
}

View File

@@ -2,6 +2,8 @@ package org.joinmastodon.android.model.viewmodel;
import org.joinmastodon.android.R;
import java.util.function.Consumer;
import androidx.annotation.DrawableRes;
import androidx.annotation.StringRes;
@@ -16,11 +18,11 @@ public class ListItem<T>{
public int iconRes;
public int colorOverrideAttr;
public boolean dividerAfter;
public Runnable onClick;
private Consumer<ListItem<T>> onClick;
public boolean isEnabled=true;
public T parentObject;
public ListItem(String title, String subtitle, int iconRes, Runnable onClick, T parentObject, int colorOverrideAttr, boolean dividerAfter){
public ListItem(String title, String subtitle, int iconRes, Consumer<ListItem<T>> onClick, T parentObject, int colorOverrideAttr, boolean dividerAfter){
this.title=title;
this.subtitle=subtitle;
this.iconRes=iconRes;
@@ -32,41 +34,41 @@ public class ListItem<T>{
isEnabled=false;
}
public ListItem(String title, String subtitle, Runnable onClick){
public ListItem(String title, String subtitle, Consumer<ListItem<T>> onClick){
this(title, subtitle, 0, onClick, null, 0, false);
}
public ListItem(String title, String subtitle, Runnable onClick, T parentObject){
public ListItem(String title, String subtitle, Consumer<ListItem<T>> onClick, T parentObject){
this(title, subtitle, 0, onClick, parentObject, 0, false);
}
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Runnable onClick){
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Consumer<ListItem<T>> onClick){
this(title, subtitle, iconRes, onClick, null, 0, false);
}
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Runnable onClick, T parentObject){
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Consumer<ListItem<T>> onClick, T parentObject){
this(title, subtitle, iconRes, onClick, parentObject, 0, false);
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Runnable onClick){
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Consumer<ListItem<T>> onClick){
this(null, null, 0, onClick, null, 0, false);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Runnable onClick, int colorOverrideAttr, boolean dividerAfter){
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Consumer<ListItem<T>> onClick, int colorOverrideAttr, boolean dividerAfter){
this(null, null, 0, onClick, null, colorOverrideAttr, dividerAfter);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Runnable onClick){
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Consumer<ListItem<T>> onClick){
this(null, null, iconRes, onClick, null, 0, false);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Runnable onClick, int colorOverrideAttr, boolean dividerAfter){
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Consumer<ListItem<T>> onClick, int colorOverrideAttr, boolean dividerAfter){
this(null, null, iconRes, onClick, null, colorOverrideAttr, dividerAfter);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
@@ -75,4 +77,13 @@ public class ListItem<T>{
public int getItemViewType(){
return colorOverrideAttr==0 ? R.id.list_item_simple : R.id.list_item_simple_tinted;
}
public void performClick(){
if(onClick!=null)
onClick.accept(this);
}
public <I extends ListItem<T>> void setOnClick(Consumer<I> onClick){
this.onClick=(Consumer<ListItem<T>>) onClick;
}
}

View File

@@ -0,0 +1,35 @@
package org.joinmastodon.android.model.viewmodel;
import android.view.Menu;
import android.view.MenuItem;
import org.joinmastodon.android.R;
import java.util.function.Consumer;
public class ListItemWithOptionsMenu<T> extends ListItem<T>{
public OptionsMenuListener<T> listener;
public ListItemWithOptionsMenu(String title, String subtitle, OptionsMenuListener<T> listener, int iconRes, Consumer<ListItemWithOptionsMenu<T>> onClick, T parentObject, boolean dividerAfter){
super(title, subtitle, iconRes, (Consumer<ListItem<T>>)(Object)onClick, parentObject, 0, dividerAfter);
this.listener=listener;
}
@Override
public int getItemViewType(){
return R.id.list_item_options;
}
public void performConfigureMenu(Menu menu){
listener.onConfigureListItemOptionsMenu(this, menu);
}
public void performItemSelected(MenuItem item){
listener.onListItemOptionSelected(this, item);
}
public interface OptionsMenuListener<T>{
void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu<T> item, Menu menu);
void onListItemOptionSelected(ListItemWithOptionsMenu<T> item, MenuItem menuItem);
}
}

View File

@@ -8,6 +8,7 @@ import android.graphics.drawable.Animatable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
@@ -36,6 +37,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.recyclerview.widget.LinearLayoutManager;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
@@ -113,9 +115,7 @@ public class AccountSwitcherSheet extends BottomSheet{
private void logOut(String accountID){
AccountSessionManager.get(accountID).logOut(activity, ()->{
dismiss();
activity.finish();
Intent intent=new Intent(activity, MainActivity.class);
activity.startActivity(intent);
((MainActivity)activity).restartHomeFragment();
});
}
@@ -248,17 +248,17 @@ public class AccountSwitcherSheet extends BottomSheet{
@Override
public void onClick(){
dismiss();
if(AccountSessionManager.getInstance().getLastActiveAccountID().equals(item.getID())){
dismiss();
if(fragment!=null){
fragment.setCurrentTab(R.id.tab_profile);
}
return;
}
if(AccountSessionManager.getInstance().tryGetAccount(item.getID())!=null)
if(AccountSessionManager.getInstance().tryGetAccount(item.getID())!=null){
AccountSessionManager.getInstance().setLastActiveAccountID(item.getID());
activity.finish();
activity.startActivity(new Intent(activity, MainActivity.class));
((MainActivity)activity).restartHomeFragment();
}
}
@Override

View File

@@ -47,7 +47,7 @@ public class SearchViewHelper{
searchEdit.setBackground(null);
searchEdit.addTextChangedListener(new SimpleTextWatcher(e->{
searchEdit.removeCallbacks(debouncer);
searchEdit.postDelayed(debouncer, 300);
searchEdit.postDelayed(debouncer, 500);
boolean newIsEmpty=e.length()==0;
if(isEmpty!=newIsEmpty){
isEmpty=newIsEmpty;

View File

@@ -3,9 +3,12 @@ package org.joinmastodon.android.ui.adapters;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.AvatarPileListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.viewholders.AvatarPileListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.CheckboxOrRadioListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.OptionsListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SwitchListItemViewHolder;
@@ -13,11 +16,21 @@ import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.views.UsableRecyclerView;
public class GenericListItemsAdapter<T> extends RecyclerView.Adapter<ListItemViewHolder<?>>{
public class GenericListItemsAdapter<T> extends UsableRecyclerView.Adapter<ListItemViewHolder<?>> implements ImageLoaderRecyclerAdapter{
private List<ListItem<T>> items;
public GenericListItemsAdapter(List<ListItem<T>> items){
super(null);
this.items=items;
}
public GenericListItemsAdapter(ListImageLoaderWrapper imgLoader, List<ListItem<T>> items){
super(imgLoader);
this.items=items;
}
@@ -32,6 +45,10 @@ public class GenericListItemsAdapter<T> extends RecyclerView.Adapter<ListItemVie
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, false);
if(viewType==R.id.list_item_radio)
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, true);
if(viewType==R.id.list_item_options)
return new OptionsListItemViewHolder(parent.getContext(), parent);
if(viewType==R.id.list_item_avatar_pile)
return new AvatarPileListItemViewHolder(parent.getContext(), parent);
throw new IllegalArgumentException("Unexpected view type "+viewType);
}
@@ -51,4 +68,20 @@ public class GenericListItemsAdapter<T> extends RecyclerView.Adapter<ListItemVie
public int getItemViewType(int position){
return items.get(position).getItemViewType();
}
@Override
public int getImageCountForItem(int position){
ListItem<?> item=items.get(position);
if(item instanceof AvatarPileListItem<?> avatarPileListItem)
return avatarPileListItem.avatars.size();
return 0;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
ListItem<?> item=items.get(position);
if(item instanceof AvatarPileListItem<?> avatarPileListItem)
return avatarPileListItem.avatars.get(image);
return null;
}
}

View File

@@ -3,6 +3,8 @@ package org.joinmastodon.android.ui.displayitems;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Build;
@@ -24,6 +26,7 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.statuses.GetStatusSourceText;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.AddAccountToListsFragment;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
@@ -198,6 +201,14 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
UiUtils.openSystemShareSheet(activity, item.status.url);
}else if(id==R.id.translate){
item.parentFragment.togglePostTranslation(item.status, item.parentID);
}else if(id==R.id.add_to_list){
Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("targetAccount", Parcels.wrap(account));
Nav.go(activity, AddAccountToListsFragment.class, args);
}else if(id==R.id.copy_link){
activity.getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, item.status.url));
UiUtils.maybeShowTextCopiedToast(activity);
}
return true;
});
@@ -326,6 +337,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
report.setTitle(item.parentFragment.getString(R.string.report_user, account.displayName));
follow.setTitle(item.parentFragment.getString(relationship!=null && relationship.following ? R.string.unfollow_user : R.string.follow_user, account.displayName));
}
menu.findItem(R.id.add_to_list).setVisible(relationship!=null && relationship.following);
}
}
}

View File

@@ -75,7 +75,8 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{
if(item.imgRequest!=null){
crossfadeDrawable.setSize(card.width, card.height);
crossfadeDrawable.setBlurhashDrawable(card.blurhashPlaceholder);
crossfadeDrawable.setCrossfadeAlpha(item.status.spoilerRevealed ? 0f : 1f);
crossfadeDrawable.setCrossfadeAlpha(0f);
photo.setImageDrawable(null);
photo.setImageDrawable(crossfadeDrawable);
didClear=false;
}
@@ -84,8 +85,14 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{
@Override
public void setImage(int index, Drawable drawable){
crossfadeDrawable.setImageDrawable(drawable);
if(didClear && item.status.spoilerRevealed)
if(didClear)
crossfadeDrawable.animateAlpha(0f);
Card card=item.status.card;
// Make sure the image is not stretched if the server returned wrong dimensions
if(drawable!=null && (drawable.getIntrinsicWidth()!=card.width || drawable.getIntrinsicHeight()!=card.height)){
photo.setImageDrawable(null);
photo.setImageDrawable(crossfadeDrawable);
}
}
@Override
@@ -95,7 +102,7 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{
}
private void onClick(View v){
UiUtils.launchWebBrowser(itemView.getContext(), item.status.card.url);
UiUtils.openURL(itemView.getContext(), item.parentFragment.getAccountID(), item.status.card.url);
}
}
}

View File

@@ -125,11 +125,11 @@ public abstract class StatusDisplayItem{
items.add(spoilerItem);
contentItems=spoilerItem.contentItems;
status.spoilerRevealed=false;
}else if(!TextUtils.isEmpty(statusForContent.spoilerText) && AccountSessionManager.get(accountID).getLocalPreferences().showCWs){
}else if(!TextUtils.isEmpty(statusForContent.spoilerText)){
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, null, status, statusForContent, Type.SPOILER);
items.add(spoilerItem);
contentItems=spoilerItem.contentItems;
status.spoilerRevealed=false;
status.spoilerRevealed=!AccountSessionManager.get(accountID).getLocalPreferences().showCWs;
}else{
contentItems=items;
}

View File

@@ -66,6 +66,10 @@ public class BlurhashCrossfadeDrawable extends Drawable{
public void setImageDrawable(Drawable imageDrawable){
this.imageDrawable=imageDrawable;
if(imageDrawable!=null){
width=imageDrawable.getIntrinsicWidth();
height=imageDrawable.getIntrinsicHeight();
}
invalidateSelf();
}

View File

@@ -716,9 +716,18 @@ public class PhotoViewer implements ZoomPanView.Listener{
public void onBind(Attachment item){
super.onBind(item);
FrameLayout.LayoutParams params=(FrameLayout.LayoutParams) imageView.getLayoutParams();
params.width=item.getWidth();
params.height=item.getHeight();
ViewImageLoader.load(this, listener.getPhotoViewCurrentDrawable(getAbsoluteAdapterPosition()), new UrlImageLoaderRequest(item.url), false);
Drawable currentDrawable=listener.getPhotoViewCurrentDrawable(getAbsoluteAdapterPosition());
if(item.hasKnownDimensions()){
params.width=item.getWidth();
params.height=item.getHeight();
}else if(currentDrawable!=null){
params.width=currentDrawable.getIntrinsicWidth();
params.height=currentDrawable.getIntrinsicHeight();
}else{
params.width=1920;
params.height=1080;
}
ViewImageLoader.load(this, currentDrawable, new UrlImageLoaderRequest(item.url), false);
}
@Override
@@ -760,9 +769,18 @@ public class PhotoViewer implements ZoomPanView.Listener{
super.onBind(item);
playerReady=false;
FrameLayout.LayoutParams params=(FrameLayout.LayoutParams) wrap.getLayoutParams();
params.width=item.getWidth();
params.height=item.getHeight();
wrap.setBackground(listener.getPhotoViewCurrentDrawable(getAbsoluteAdapterPosition()));
Drawable currentDrawable=listener.getPhotoViewCurrentDrawable(getAbsoluteAdapterPosition());
if(item.hasKnownDimensions()){
params.width=item.getWidth();
params.height=item.getHeight();
}else if(currentDrawable!=null){
params.width=currentDrawable.getIntrinsicWidth();
params.height=currentDrawable.getIntrinsicHeight();
}else{
params.width=1920;
params.height=1080;
}
wrap.setBackground(currentDrawable);
progressBar.setVisibility(item.type==Attachment.Type.VIDEO ? View.VISIBLE : View.GONE);
if(itemView.isAttachedToWindow()){
reset();
@@ -822,7 +840,9 @@ public class PhotoViewer implements ZoomPanView.Listener{
@Override
public boolean onError(MediaPlayer mp, int what, int extra){
Log.e(TAG, "video player onError() called with: mp = ["+mp+"], what = ["+what+"], extra = ["+extra+"]");
return false;
Toast.makeText(activity, R.string.error_playing_video, Toast.LENGTH_SHORT).show();
onStartSwipeToDismissTransition(0f);
return true;
}
public void prepareAndStartPlayer(){
@@ -843,6 +863,8 @@ public class PhotoViewer implements ZoomPanView.Listener{
player.prepareAsync();
}catch(IOException x){
Log.w(TAG, "Error initializing gif player", x);
Toast.makeText(activity, R.string.error_playing_video, Toast.LENGTH_SHORT).show();
onStartSwipeToDismissTransition(0f);
}
}

View File

@@ -119,8 +119,10 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
int width=right-left;
int height=bottom-top;
if(width==0 || height==0)
if(width==0 || height==0 || child.getWidth()==0 || child.getWidth()==0){
matrix.reset();
return;
}
float scale=Math.min(width/(float)child.getWidth(), height/(float)child.getHeight());
minScale=scale;

View File

@@ -18,6 +18,7 @@ import android.widget.TextView;
import android.widget.Toast;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.CustomViewHelper;
@@ -130,11 +131,7 @@ public class ClickableLinksDelegate implements CustomViewHelper{
//copy link text to clipboard
ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
clipboard.setPrimaryClip(ClipData.newPlainText("", selectedSpan.getLink()));
//show toast, android from S_V2 on has built-in popup, as documented in
//https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(view.getContext(), R.string.text_copied, Toast.LENGTH_SHORT).show();
}
UiUtils.maybeShowTextCopiedToast(view.getContext());
//reset view
resetAndInvalidate();
}

View File

@@ -0,0 +1,89 @@
package org.joinmastodon.android.ui.utils;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.IntEvaluator;
import android.animation.ObjectAnimator;
import android.graphics.drawable.Drawable;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import java.util.function.IntSupplier;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.fragments.AppKitFragment;
public class ActionModeHelper{
public static ActionMode startActionMode(AppKitFragment fragment, IntSupplier statusBarColorSupplier, ActionMode.Callback callback){
FragmentStackActivity activity=(FragmentStackActivity) fragment.getActivity();
return activity.startActionMode(new ActionMode.Callback(){
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu){
if(!callback.onCreateActionMode(mode, menu))
return false;
ObjectAnimator anim=ObjectAnimator.ofInt(activity.getWindow(), "statusBarColor", statusBarColorSupplier.getAsInt(), UiUtils.getThemeColor(activity, R.attr.colorM3Primary));
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.start();
activity.invalidateSystemBarColors(fragment);
View fakeView=new View(activity);
// mode.setCustomView(fakeView);
// int buttonID=activity.getResources().getIdentifier("action_mode_close_button", "id", "android");
// View btn=activity.getWindow().getDecorView().findViewById(buttonID);
// if(btn!=null){
// ((ViewGroup.MarginLayoutParams)btn.getLayoutParams()).setMarginEnd(0);
// }
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu){
if(!callback.onPrepareActionMode(mode, menu))
return false;
for(int i=0;i<menu.size();i++){
Drawable icon=menu.getItem(i).getIcon();
if(icon!=null){
icon=icon.mutate();
icon.setTint(UiUtils.getThemeColor(activity, R.attr.colorM3OnPrimary));
menu.getItem(i).setIcon(icon);
}
}
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item){
return callback.onActionItemClicked(mode, item);
}
@Override
public void onDestroyActionMode(ActionMode mode){
ObjectAnimator anim=ObjectAnimator.ofInt(activity.getWindow(), "statusBarColor", UiUtils.getThemeColor(activity, R.attr.colorM3Primary), statusBarColorSupplier.getAsInt());
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
activity.getWindow().setStatusBarColor(0);
}
});
anim.start();
activity.invalidateSystemBarColors(fragment);
callback.onDestroyActionMode(mode);
}
});
}
}

View File

@@ -22,6 +22,8 @@ public class DiscoverInfoBannerHelper{
private final BannerType type;
private final String accountID;
private static EnumSet<BannerType> bannerTypesToShow=EnumSet.noneOf(BannerType.class);
private SingleViewRecyclerAdapter bannerAdapter;
private boolean added;
static{
for(BannerType t:BannerType.values()){
@@ -40,6 +42,8 @@ public class DiscoverInfoBannerHelper{
}
public void maybeAddBanner(RecyclerView list, MergeRecyclerAdapter adapter){
if(added)
return;
if(bannerTypesToShow.contains(type)){
banner=((Activity)list.getContext()).getLayoutInflater().inflate(R.layout.discover_info_banner, list, false);
TextView text=banner.findViewById(R.id.banner_text);
@@ -56,7 +60,8 @@ public class DiscoverInfoBannerHelper{
case LOCAL_TIMELINE -> R.drawable.ic_stream_24px;
case ACCOUNTS -> R.drawable.ic_group_add_24px;
});
adapter.addAdapter(new SingleViewRecyclerAdapter(banner));
adapter.addAdapter(0, bannerAdapter=new SingleViewRecyclerAdapter(banner));
added=true;
}
}
@@ -65,6 +70,13 @@ public class DiscoverInfoBannerHelper{
// bannerTypesToShow is not updated here on purpose so the banner keeps showing until the app is relaunched
}
public void removeBanner(MergeRecyclerAdapter adapter){
if(bannerAdapter!=null){
adapter.removeAdapter(bannerAdapter);
added=false;
}
}
public static void reset(){
SharedPreferences prefs=getPrefs();
SharedPreferences.Editor e=prefs.edit();

View File

@@ -27,6 +27,7 @@ public class MediaAttachmentViewController{
private final Context context;
private boolean didClear;
private Status status;
private Attachment attachment;
public MediaAttachmentViewController(Context context, MediaGridStatusDisplayItem.GridItemType type){
view=context.getSystemService(LayoutInflater.class).inflate(switch(type){
@@ -50,6 +51,7 @@ public class MediaAttachmentViewController{
public void bind(Attachment attachment, Status status){
this.status=status;
this.attachment=attachment;
crossfadeDrawable.setSize(attachment.getWidth(), attachment.getHeight());
crossfadeDrawable.setBlurhashDrawable(attachment.blurhashPlaceholder);
crossfadeDrawable.setCrossfadeAlpha(0f);
@@ -69,6 +71,11 @@ public class MediaAttachmentViewController{
crossfadeDrawable.setImageDrawable(drawable);
if(didClear)
crossfadeDrawable.animateAlpha(0f);
// Make sure the image is not stretched if the server returned wrong dimensions
if(drawable!=null && (drawable.getIntrinsicWidth()!=attachment.getWidth() || drawable.getIntrinsicHeight()!=attachment.getHeight())){
photo.setImageDrawable(null);
photo.setImageDrawable(crossfadeDrawable);
}
}
public void clearImage(){

View File

@@ -45,12 +45,14 @@ import android.widget.Toolbar;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.SetAccountBlocked;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.requests.accounts.SetAccountMuted;
import org.joinmastodon.android.api.requests.accounts.SetDomainBlocked;
import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.api.requests.statuses.DeleteStatus;
import org.joinmastodon.android.api.requests.statuses.GetStatusByID;
import org.joinmastodon.android.api.session.AccountSessionManager;
@@ -63,6 +65,7 @@ import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.text.CustomEmojiSpan;
@@ -615,10 +618,10 @@ public class UiUtils{
public static void openURL(Context context, String accountID, String url){
Uri uri=Uri.parse(url);
if(accountID!=null && "https".equals(uri.getScheme()) && AccountSessionManager.getInstance().getAccount(accountID).domain.equalsIgnoreCase(uri.getAuthority())){
if(accountID!=null && "https".equals(uri.getScheme())){
List<String> path=uri.getPathSegments();
// Match URLs like https://mastodon.social/@Gargron/108132679274083591
if(path.size()==2 && path.get(0).matches("^@[a-zA-Z0-9_]+$") && path.get(1).matches("^[0-9]+$")){
if(AccountSessionManager.getInstance().getAccount(accountID).domain.equalsIgnoreCase(uri.getAuthority()) && path.size()==2 && path.get(0).matches("^@[a-zA-Z0-9_]+$") && path.get(1).matches("^[0-9]+$")){
// Match URLs like https://mastodon.social/@Gargron/108132679274083591
new GetStatusByID(path.get(1))
.setCallback(new Callback<>(){
@Override
@@ -638,6 +641,32 @@ public class UiUtils{
.wrapProgress((Activity)context, R.string.loading, true)
.exec(accountID);
return;
}else{
new GetSearchResults(url, null, true, null, 0, 0)
.setCallback(new Callback<>(){
@Override
public void onSuccess(SearchResults result){
Bundle args=new Bundle();
args.putString("account", accountID);
if(result.statuses!=null && !result.statuses.isEmpty()){
args.putParcelable("status", Parcels.wrap(result.statuses.get(0)));
Nav.go((Activity)context, ThreadFragment.class, args);
}else if(result.accounts!=null && !result.accounts.isEmpty()){
args.putParcelable("profileAccount", Parcels.wrap(result.accounts.get(0)));
Nav.go((Activity)context, ProfileFragment.class, args);
}else{
launchWebBrowser(context, url);
}
}
@Override
public void onError(ErrorResponse error){
launchWebBrowser(context, url);
}
})
.wrapProgress((Activity)context, R.string.loading, true)
.exec(accountID);
return;
}
}
launchWebBrowser(context, url);
@@ -761,6 +790,17 @@ public class UiUtils{
return insets;
}
public static void applyBottomInsetToFAB(View fab, WindowInsets insets){
int inset;
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0 /*&& wantsOverlaySystemNavigation()*/){
int bottomInset=insets.getSystemWindowInsetBottom();
inset=bottomInset>0 ? Math.max(V.dp(40), bottomInset) : 0;
}else{
inset=0;
}
((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset;
}
public static String formatDuration(Context context, int seconds){
if(seconds<3600){
int minutes=seconds/60;
@@ -783,4 +823,12 @@ public class UiUtils{
intent.putExtra(Intent.EXTRA_TEXT, url);
context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_toot_title)));
}
public static void maybeShowTextCopiedToast(Context context){
//show toast, android from S_V2 on has built-in popup, as documented in
//https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications
if(Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2){
Toast.makeText(context, R.string.text_copied, Toast.LENGTH_SHORT).show();
}
}
}

View File

@@ -125,7 +125,7 @@ public class ComposeMediaViewController{
updateMediaAttachmentsLayout();
}
}
public boolean addMediaAttachment(Uri uri, String description){
if(getMediaAttachmentsCount()==MAX_ATTACHMENTS){
showMediaAttachmentError(fragment.getResources().getQuantityString(R.plurals.cant_add_more_than_x_attachments, MAX_ATTACHMENTS, MAX_ATTACHMENTS));

View File

@@ -0,0 +1,190 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.List;
import java.util.function.Consumer;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class DropdownSubmenuController{
protected List<Item<?>> items;
protected LinearLayout contentView;
protected UsableRecyclerView list;
protected TextView backItem;
protected final ToolbarDropdownMenuController dropdownController;
protected MergeRecyclerAdapter mergeAdapter;
protected ItemsAdapter itemsAdapter;
public DropdownSubmenuController(ToolbarDropdownMenuController dropdownController){
this.dropdownController=dropdownController;
}
protected abstract CharSequence getBackItemTitle();
public void onDismiss(){}
protected void createView(){
contentView=new LinearLayout(dropdownController.getActivity());
contentView.setOrientation(LinearLayout.VERTICAL);
CharSequence backTitle=getBackItemTitle();
if(!TextUtils.isEmpty(backTitle)){
backItem=(TextView) dropdownController.getActivity().getLayoutInflater().inflate(R.layout.item_dropdown_menu, contentView, false);
((LinearLayout.LayoutParams) backItem.getLayoutParams()).topMargin=V.dp(8);
backItem.setText(backTitle);
backItem.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_arrow_back, 0, 0, 0);
backItem.setBackground(UiUtils.getThemeDrawable(dropdownController.getActivity(), android.R.attr.selectableItemBackground));
backItem.setOnClickListener(v->dropdownController.popSubmenuController());
backItem.setAccessibilityDelegate(new View.AccessibilityDelegate(){
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){
super.onInitializeAccessibilityNodeInfo(host, info);
info.setText(info.getText()+". "+host.getResources().getString(R.string.back));
}
});
contentView.addView(backItem);
}
list=new UsableRecyclerView(dropdownController.getActivity());
list.setLayoutManager(new LinearLayoutManager(dropdownController.getActivity()));
itemsAdapter=new ItemsAdapter();
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(itemsAdapter);
list.setAdapter(mergeAdapter);
list.setPadding(0, backItem!=null ? 0 : V.dp(8), 0, V.dp(8));
list.setClipToPadding(false);
list.setItemAnimator(new BetterItemAnimator());
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private final Paint paint=new Paint();
{
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
paint.setColor(UiUtils.getThemeColor(dropdownController.getActivity(), R.attr.colorM3OutlineVariant));
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
for(int i=0;i<parent.getChildCount();i++){
View view=parent.getChildAt(i);
if(parent.getChildViewHolder(view) instanceof ItemHolder ih && ih.getItem().dividerBefore){
paint.setAlpha(Math.round(view.getAlpha()*255));
float y=view.getTop()-V.dp(8)-paint.getStrokeWidth()/2f;
c.drawLine(0, y, parent.getWidth(), y, paint);
}
}
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if(parent.getChildViewHolder(view) instanceof ItemHolder ih && ih.getItem().dividerBefore){
outRect.top=V.dp(17);
}
}
});
contentView.addView(list, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
}
public View getView(){
if(contentView==null)
createView();
return contentView;
}
protected HideableSingleViewRecyclerAdapter createEmptyView(@DrawableRes int icon, @StringRes int title, @StringRes int subtitle){
View view=dropdownController.getActivity().getLayoutInflater().inflate(R.layout.popup_menu_empty, list, false);
ImageView iconView=view.findViewById(R.id.icon);
TextView titleView=view.findViewById(R.id.title);
TextView subtitleView=view.findViewById(R.id.subtitle);
iconView.setImageResource(icon);
titleView.setText(title);
subtitleView.setText(subtitle);
return new HideableSingleViewRecyclerAdapter(view);
}
protected final class Item<T>{
public final String title;
public final boolean hasSubmenu;
public final boolean dividerBefore;
public final T parentObject;
public final Consumer<Item<T>> onClick;
public Item(String title, boolean hasSubmenu, boolean dividerBefore, T parentObject, Consumer<Item<T>> onClick){
this.title=title;
this.hasSubmenu=hasSubmenu;
this.dividerBefore=dividerBefore;
this.parentObject=parentObject;
this.onClick=onClick;
}
public Item(String title, boolean hasSubmenu, boolean dividerBefore, Consumer<Item<T>> onClick){
this(title, hasSubmenu, dividerBefore, null, onClick);
}
public Item(@StringRes int titleRes, boolean hasSubmenu, boolean dividerBefore, Consumer<Item<T>> onClick){
this(dropdownController.getActivity().getString(titleRes), hasSubmenu, dividerBefore, null, onClick);
}
private void performClick(){
onClick.accept(this);
}
}
protected class ItemsAdapter extends RecyclerView.Adapter<ItemHolder>{
@NonNull
@Override
public ItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new ItemHolder();
}
@Override
public void onBindViewHolder(@NonNull ItemHolder holder, int position){
holder.bind(items.get(position));
}
@Override
public int getItemCount(){
return items.size();
}
}
private class ItemHolder extends BindableViewHolder<Item<?>> implements UsableRecyclerView.Clickable{
private final TextView text;
public ItemHolder(){
super(dropdownController.getActivity(), R.layout.item_dropdown_menu, list);
text=(TextView) itemView;
}
@Override
public void onBind(Item<?> item){
text.setText(item.title);
text.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, item.hasSubmenu ? R.drawable.ic_arrow_right_24px : 0, 0);
}
@Override
public void onClick(){
item.performClick();
}
}
}

View File

@@ -0,0 +1,111 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.app.Activity;
import android.os.Bundle;
import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.tags.GetFollowedTags;
import org.joinmastodon.android.fragments.ManageFollowedHashtagsFragment;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.utils.V;
public class HomeTimelineHashtagsMenuController extends DropdownSubmenuController{
private HideableSingleViewRecyclerAdapter largeProgressAdapter;
private HideableSingleViewRecyclerAdapter emptyAdapter;
private APIRequest<?> currentRequest;
public HomeTimelineHashtagsMenuController(ToolbarDropdownMenuController dropdownController){
super(dropdownController);
items=new ArrayList<>();
loadHashtags();
}
@Override
protected void createView(){
super.createView();
emptyAdapter=createEmptyView(R.drawable.ic_tag_24px, R.string.no_followed_hashtags_title, R.string.no_followed_hashtags_subtitle);
FrameLayout largeProgressView=new FrameLayout(dropdownController.getActivity());
int pad=V.dp(32);
largeProgressView.setPadding(0, pad, 0, pad);
largeProgressView.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
ProgressBar progress=new ProgressBar(dropdownController.getActivity());
largeProgressView.addView(progress, new FrameLayout.LayoutParams(V.dp(48), V.dp(48), Gravity.CENTER));
largeProgressAdapter=new HideableSingleViewRecyclerAdapter(largeProgressView);
mergeAdapter.addAdapter(0, largeProgressAdapter);
emptyAdapter.setVisible(false);
mergeAdapter.addAdapter(0, emptyAdapter);
}
@Override
protected CharSequence getBackItemTitle(){
return dropdownController.getActivity().getString(R.string.followed_hashtags);
}
@Override
public void onDismiss(){
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
}
private void onTagClick(Item<Hashtag> item){
dropdownController.dismiss();
UiUtils.openHashtagTimeline(dropdownController.getActivity(), dropdownController.getAccountID(), item.parentObject);
}
private void onManageTagsClick(){
dropdownController.dismiss();
Bundle args=new Bundle();
args.putString("account", dropdownController.getAccountID());
Nav.go(dropdownController.getActivity(), ManageFollowedHashtagsFragment.class, args);
}
private void loadHashtags(){
currentRequest=new GetFollowedTags(null, 200)
.setCallback(new Callback<>(){
@Override
public void onSuccess(HeaderPaginationList<Hashtag> result){
currentRequest=null;
dropdownController.resizeOnNextFrame();
largeProgressAdapter.setVisible(false);
((List<Hashtag>) result).sort(Comparator.comparing(tag->tag.name));
int prevSize=items.size();
for(Hashtag tag:result){
items.add(new Item<>("#"+tag.name, false, false, tag, HomeTimelineHashtagsMenuController.this::onTagClick));
}
items.add(new Item<Void>(R.string.manage_hashtags, false, true, i->onManageTagsClick()));
itemsAdapter.notifyItemRangeInserted(prevSize, result.size()+1);
emptyAdapter.setVisible(result.isEmpty());
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
Activity activity=dropdownController.getActivity();
if(activity!=null)
error.showToast(activity);
dropdownController.popSubmenuController();
}
})
.exec(dropdownController.getAccountID());
}
}

View File

@@ -0,0 +1,61 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.CreateListFragment;
import org.joinmastodon.android.fragments.ManageListsFragment;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import java.util.ArrayList;
import java.util.List;
import me.grishka.appkit.Nav;
public class HomeTimelineListsMenuController extends DropdownSubmenuController{
private final List<FollowList> lists;
private final HomeTimelineMenuController.Callback callback;
private HideableSingleViewRecyclerAdapter emptyAdapter;
public HomeTimelineListsMenuController(ToolbarDropdownMenuController dropdownController, HomeTimelineMenuController.Callback callback){
super(dropdownController);
this.lists=new ArrayList<>(callback.getLists());
this.callback=callback;
items=new ArrayList<>();
for(FollowList l:lists){
items.add(new Item<>(l.title, false, false, l, this::onListSelected));
}
items.add(new Item<Void>(dropdownController.getActivity().getString(R.string.create_list), false, true, i->{
dropdownController.dismiss();
Bundle args=new Bundle();
args.putString("account", dropdownController.getAccountID());
Nav.go(dropdownController.getActivity(), CreateListFragment.class, args);
}));
items.add(new Item<Void>(dropdownController.getActivity().getString(R.string.manage_lists), false, false, i->{
dropdownController.dismiss();
Bundle args=new Bundle();
args.putString("account", dropdownController.getAccountID());
Nav.go(dropdownController.getActivity(), ManageListsFragment.class, args);
}));
}
@Override
protected CharSequence getBackItemTitle(){
return dropdownController.getActivity().getString(R.string.lists);
}
@Override
protected void createView(){
super.createView();
emptyAdapter=createEmptyView(R.drawable.ic_list_alt_24px, R.string.no_lists_title, R.string.no_lists_subtitle);
if(lists.isEmpty()){
mergeAdapter.addAdapter(0, emptyAdapter);
}
}
private void onListSelected(Item<FollowList> item){
callback.onListSelected(item.parentObject);
dropdownController.dismiss();
}
}

View File

@@ -0,0 +1,39 @@
package org.joinmastodon.android.ui.viewcontrollers;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.FollowList;
import java.util.List;
public class HomeTimelineMenuController extends DropdownSubmenuController{
private Callback callback;
public HomeTimelineMenuController(ToolbarDropdownMenuController dropdownController, Callback callback){
super(dropdownController);
this.callback=callback;
items=List.of(
new Item<Void>(R.string.timeline_following, false, false, i->{
callback.onFollowingSelected();
dropdownController.dismiss();
}),
new Item<Void>(R.string.local_timeline, false, false, i->{
callback.onLocalSelected();
dropdownController.dismiss();
}),
new Item<Void>(R.string.lists, true, true, i->dropdownController.pushSubmenuController(new HomeTimelineListsMenuController(dropdownController, callback))),
new Item<Void>(R.string.followed_hashtags, true, false, i->dropdownController.pushSubmenuController(new HomeTimelineHashtagsMenuController(dropdownController)))
);
}
@Override
protected CharSequence getBackItemTitle(){
return null;
}
public interface Callback{
void onFollowingSelected();
void onLocalSelected();
List<FollowList> getLists();
void onListSelected(FollowList list);
}
}

View File

@@ -0,0 +1,268 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.OutlineProviders;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class ToolbarDropdownMenuController{
private final HostFragment fragment;
private FrameLayout windowView;
private FrameLayout menuContainer;
private boolean dismissing;
private List<DropdownSubmenuController> controllerStack=new ArrayList<>();
private Animator currentTransition;
public ToolbarDropdownMenuController(HostFragment fragment){
this.fragment=fragment;
}
public void show(DropdownSubmenuController initialSubmenu){
if(windowView!=null)
return;
menuContainer=new FrameLayout(fragment.getActivity());
menuContainer.setBackgroundResource(R.drawable.bg_m3_surface2);
menuContainer.setOutlineProvider(OutlineProviders.roundedRect(4));
menuContainer.setClipToOutline(true);
menuContainer.setElevation(V.dp(6));
View menuView=initialSubmenu.getView();
menuView.setVisibility(View.VISIBLE);
menuContainer.addView(menuView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
windowView=new WindowView(fragment.getActivity());
int pad=V.dp(16);
windowView.setPadding(pad, fragment.getToolbar().getHeight(), pad, pad);
windowView.setClipToPadding(false);
windowView.addView(menuContainer, new FrameLayout.LayoutParams(V.dp(200), ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP | Gravity.START));
WindowManager.LayoutParams wlp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION_PANEL);
wlp.format=PixelFormat.TRANSLUCENT;
wlp.token=fragment.getActivity().getWindow().getDecorView().getWindowToken();
wlp.width=wlp.height=ViewGroup.LayoutParams.MATCH_PARENT;
wlp.flags=WindowManager.LayoutParams.FLAG_LAYOUT_ATTACHED_IN_DECOR;
wlp.setTitle(fragment.getActivity().getString(R.string.dropdown_menu));
fragment.getActivity().getWindowManager().addView(windowView, wlp);
menuContainer.setPivotX(V.dp(100));
menuContainer.setPivotY(0);
menuContainer.setScaleX(.8f);
menuContainer.setScaleY(.8f);
menuContainer.setAlpha(0f);
menuContainer.animate()
.scaleX(1f)
.scaleY(1f)
.alpha(1f)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.setDuration(150)
.withLayer()
.start();
controllerStack.add(initialSubmenu);
}
public void dismiss(){
if(windowView==null || dismissing)
return;
dismissing=true;
fragment.onDropdownWillDismiss();
menuContainer.animate()
.scaleX(.8f)
.scaleY(.8f)
.alpha(0f)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.setDuration(150)
.withLayer()
.withEndAction(()->{
controllerStack.clear();
fragment.getActivity().getWindowManager().removeView(windowView);
menuContainer.removeAllViews();
dismissing=false;
windowView=null;
menuContainer=null;
fragment.onDropdownDismissed();
})
.start();
}
public void pushSubmenuController(DropdownSubmenuController controller){
View prevView=menuContainer.getChildAt(menuContainer.getChildCount()-1);
View newView=controller.getView();
newView.setVisibility(View.VISIBLE);
menuContainer.addView(newView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
controllerStack.add(controller);
animateTransition(prevView, newView, true);
}
public void popSubmenuController(){
if(menuContainer.getChildCount()<=1)
throw new IllegalStateException();
DropdownSubmenuController controller=controllerStack.remove(controllerStack.size()-1);
controller.onDismiss();
View top=menuContainer.getChildAt(menuContainer.getChildCount()-1);
View prev=menuContainer.getChildAt(menuContainer.getChildCount()-2);
prev.setVisibility(View.VISIBLE);
animateTransition(prev, top, false);
}
private void animateTransition(View bottomView, View topView, boolean adding){
if(currentTransition!=null)
currentTransition.cancel();
int origBottom=menuContainer.getBottom();
menuContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
private final Rect tmpRect=new Rect();
@Override
public boolean onPreDraw(){
menuContainer.getViewTreeObserver().removeOnPreDrawListener(this);
AnimatorSet set=new AnimatorSet();
ObjectAnimator slideIn;
set.playTogether(
ObjectAnimator.ofInt(menuContainer, "bottom", origBottom, menuContainer.getTop()+(adding ? topView : bottomView).getHeight()),
slideIn=ObjectAnimator.ofFloat(topView, View.TRANSLATION_X, adding ? menuContainer.getWidth() : 0, adding ? 0 : menuContainer.getWidth()),
ObjectAnimator.ofFloat(bottomView, View.TRANSLATION_X, adding ? 0 : -menuContainer.getWidth()/4f, adding ? -menuContainer.getWidth()/4f : 0),
ObjectAnimator.ofFloat(bottomView, View.ALPHA, adding ? 1f : 0f, adding ? 0f : 1f)
);
set.setDuration(300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
bottomView.setClipBounds(null);
bottomView.setTranslationX(0);
bottomView.setAlpha(1f);
topView.setTranslationX(0);
topView.setAlpha(1f);
if(adding){
bottomView.setVisibility(View.GONE);
}else{
menuContainer.removeView(topView);
}
currentTransition=null;
}
});
slideIn.addUpdateListener(animation->{
tmpRect.set(0, 0, Math.round(topView.getX()-bottomView.getX()), bottomView.getHeight());
bottomView.setClipBounds(tmpRect);
});
currentTransition=set;
set.start();
return true;
}
});
}
public void resizeOnNextFrame(){
if(currentTransition!=null)
currentTransition.cancel();
int origBottom=menuContainer.getBottom();
menuContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
menuContainer.getViewTreeObserver().removeOnPreDrawListener(this);
ObjectAnimator anim=ObjectAnimator.ofInt(menuContainer, "bottom", origBottom, menuContainer.getBottom());
anim.setDuration(300);
anim.setInterpolator(CubicBezierInterpolator.DEFAULT);
anim.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
currentTransition=null;
}
});
currentTransition=anim;
anim.start();
return true;
}
});
}
Activity getActivity(){
return fragment.getActivity();
}
String getAccountID(){
return fragment.getAccountID();
}
private class WindowView extends FrameLayout{
private final Rect tmpRect=new Rect();
public WindowView(@NonNull Context context){
super(context);
}
@Override
public boolean onTouchEvent(MotionEvent ev){
for(int i=0;i<getChildCount();i++){
View child=getChildAt(i);
child.getHitRect(tmpRect);
if(tmpRect.contains(Math.round(ev.getX()), Math.round(ev.getY())))
return super.onTouchEvent(ev);
}
if(ev.getAction()==MotionEvent.ACTION_DOWN){
dismiss();
}
return true;
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev){
if(currentTransition!=null)
return false;
return super.dispatchTouchEvent(ev);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event){
if(event.getKeyCode()==KeyEvent.KEYCODE_BACK){
if(event.getAction()==KeyEvent.ACTION_DOWN){
if(controllerStack.size()>1)
popSubmenuController();
else
dismiss();
}
return true;
}
return super.dispatchKeyEvent(event);
}
}
public interface HostFragment{
// Fragment methods
Activity getActivity();
Resources getResources();
Toolbar getToolbar();
String getAccountID();
// Callbacks
void onDropdownWillDismiss();
void onDropdownDismissed();
}
}

View File

@@ -17,6 +17,7 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.ProgressBar;
@@ -26,6 +27,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.AddAccountToListsFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
@@ -40,6 +42,7 @@ import org.parceler.Parcels;
import java.util.HashMap;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Predicate;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
@@ -58,12 +61,15 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
private final CheckableRelativeLayout view;
private final View checkbox;
private final ProgressBar actionProgress;
private final ImageButton menuButton;
private final String accountID;
private final Fragment fragment;
private final HashMap<String, Relationship> relationships;
private Consumer<AccountViewHolder> onClick;
private Predicate<AccountViewHolder> onLongClick;
private Consumer<MenuItem> onCustomMenuItemSelected;
private AccessoryType accessoryType;
private boolean showBio;
private boolean checked;
@@ -85,6 +91,7 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
bio=findViewById(R.id.bio);
checkbox=findViewById(R.id.checkbox);
actionProgress=findViewById(R.id.action_progress);
menuButton=findViewById(R.id.options_btn);
avatar.setOutlineProvider(OutlineProviders.roundedRect(10));
avatar.setClipToOutline(true);
@@ -94,6 +101,7 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
contextMenu=new PopupMenu(fragment.getActivity(), menuAnchor);
contextMenu.inflate(R.menu.profile);
contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected);
menuButton.setOnClickListener(v->showMenuFromButton());
setStyle(AccessoryType.BUTTON, false);
}
@@ -181,37 +189,13 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
@Override
public boolean onLongClick(float x, float y){
if(relationships==null)
if(onLongClick!=null && onLongClick.test(this))
return true;
if(accessoryType==AccessoryType.MENU || !prepareMenu())
return false;
Relationship relationship=relationships.get(item.account.id);
if(relationship==null)
return false;
Menu menu=contextMenu.getMenu();
Account account=item.account;
menu.findItem(R.id.share).setTitle(fragment.getString(R.string.share_user, account.getDisplayUsername()));
menu.findItem(R.id.mute).setTitle(fragment.getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
menu.findItem(R.id.block).setTitle(fragment.getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername()));
menu.findItem(R.id.report).setTitle(fragment.getString(R.string.report_user, account.getDisplayUsername()));
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
if(relationship.following){
hideBoosts.setTitle(fragment.getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
hideBoosts.setVisible(true);
}else{
hideBoosts.setVisible(false);
}
MenuItem blockDomain=menu.findItem(R.id.block_domain);
if(!account.isLocal()){
blockDomain.setTitle(fragment.getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
blockDomain.setVisible(true);
}else{
blockDomain.setVisible(false);
}
menuAnchor.setTranslationX(x);
menuAnchor.setTranslationY(y);
contextMenu.show();
return true;
}
@@ -226,7 +210,7 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
});
}
private void setActionProgressVisible(boolean visible){
public void setActionProgressVisible(boolean visible){
if(visible)
actionProgress.setIndeterminateTintList(button.getTextColors());
button.setTextVisible(!visible);
@@ -279,6 +263,13 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
})
.wrapProgress(fragment.getActivity(), R.string.loading, false)
.exec(accountID);
}else if(id==R.id.add_to_list){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("targetAccount", Parcels.wrap(account));
Nav.go(fragment.getActivity(), AddAccountToListsFragment.class, args);
}else if(onCustomMenuItemSelected!=null){
onCustomMenuItemSelected.accept(item);
}
return true;
}
@@ -292,6 +283,14 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
onClick=listener;
}
public void setOnLongClickListener(Predicate<AccountViewHolder> onLongClick){
this.onLongClick=onLongClick;
}
public void setOnCustomMenuItemSelectedListener(Consumer<MenuItem> onCustomMenuItemSelected){
this.onCustomMenuItemSelected=onCustomMenuItemSelected;
}
public void setStyle(AccessoryType accessoryType, boolean showBio){
if(accessoryType!=this.accessoryType){
this.accessoryType=accessoryType;
@@ -299,20 +298,29 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
case NONE -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.GONE);
menuButton.setVisibility(View.GONE);
}
case CHECKBOX -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.VISIBLE);
menuButton.setVisibility(View.GONE);
checkbox.setBackground(new CheckBox(checkbox.getContext()).getButtonDrawable());
}
case RADIOBUTTON -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.VISIBLE);
menuButton.setVisibility(View.GONE);
checkbox.setBackground(new RadioButton(checkbox.getContext()).getButtonDrawable());
}
case BUTTON -> {
case BUTTON, CUSTOM_BUTTON -> {
button.setVisibility(View.VISIBLE);
checkbox.setVisibility(View.GONE);
menuButton.setVisibility(View.GONE);
}
case MENU -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.GONE);
menuButton.setVisibility(View.VISIBLE);
}
}
view.setCheckable(accessoryType==AccessoryType.CHECKBOX || accessoryType==AccessoryType.RADIOBUTTON);
@@ -321,15 +329,68 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
bio.setVisibility(showBio ? View.VISIBLE : View.GONE);
}
private boolean prepareMenu(){
if(relationships==null)
return false;
Relationship relationship=relationships.get(item.account.id);
if(relationship==null)
return false;
Menu menu=contextMenu.getMenu();
Account account=item.account;
menu.findItem(R.id.share).setTitle(fragment.getString(R.string.share_user, account.getDisplayUsername()));
menu.findItem(R.id.mute).setTitle(fragment.getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
menu.findItem(R.id.block).setTitle(fragment.getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername()));
menu.findItem(R.id.report).setTitle(fragment.getString(R.string.report_user, account.getDisplayUsername()));
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
if(relationship.following){
hideBoosts.setTitle(fragment.getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
hideBoosts.setVisible(true);
}else{
hideBoosts.setVisible(false);
}
MenuItem blockDomain=menu.findItem(R.id.block_domain);
if(!account.isLocal()){
blockDomain.setTitle(fragment.getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
blockDomain.setVisible(true);
}else{
blockDomain.setVisible(false);
}
menu.findItem(R.id.add_to_list).setVisible(relationship.following);
return true;
}
private void showMenuFromButton(){
if(!prepareMenu())
return;
int[] xy={0, 0};
itemView.getLocationInWindow(xy);
int x=xy[0], y=xy[1];
menuButton.getLocationInWindow(xy);
menuAnchor.setTranslationX(xy[0]-x+menuButton.getWidth()/2f);
menuAnchor.setTranslationY(xy[1]-y+menuButton.getHeight());
contextMenu.show();
}
public void setChecked(boolean checked){
this.checked=checked;
view.setChecked(checked);
}
public PopupMenu getContextMenu(){
return contextMenu;
}
public ProgressBarButton getButton(){
return button;
}
public enum AccessoryType{
NONE,
BUTTON,
CHECKBOX,
RADIOBUTTON
RADIOBUTTON,
MENU,
CUSTOM_BUTTON
}
}

View File

@@ -0,0 +1,42 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.AvatarPileListItem;
import org.joinmastodon.android.ui.views.AvatarPileView;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.utils.V;
public class AvatarPileListItemViewHolder extends ListItemViewHolder<AvatarPileListItem<?>> implements ImageLoaderViewHolder{
private final AvatarPileView pile;
public AvatarPileListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list, parent);
pile=new AvatarPileView(context);
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT);
lp.topMargin=lp.bottomMargin=V.dp(-8);
view.addView(pile, lp);
view.setClipToPadding(false);
}
@Override
public void onBind(AvatarPileListItem<?> item){
super.onBind(item);
pile.setVisibleAvatarCount(item.avatars.size());
}
@Override
public void setImage(int index, Drawable image){
pile.avatars[index].setImageDrawable(image);
}
@Override
public void clearImage(int index){
pile.avatars[index].setImageResource(R.drawable.image_placeholder);
}
}

View File

@@ -75,6 +75,6 @@ public abstract class ListItemViewHolder<T extends ListItem<?>> extends Bindable
@Override
public void onClick(){
item.onClick.run();
item.performClick();
}
}

View File

@@ -0,0 +1,33 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.PopupMenu;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu;
public class OptionsListItemViewHolder extends ListItemViewHolder<ListItemWithOptionsMenu<?>>{
private final PopupMenu menu;
private final ImageButton menuBtn;
public OptionsListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list_options, parent);
menuBtn=findViewById(R.id.options_btn);
menu=new PopupMenu(context, menuBtn);
menuBtn.setOnClickListener(this::onMenuBtnClick);
menu.setOnMenuItemClickListener(menuItem->{
item.performItemSelected(menuItem);
return true;
});
}
private void onMenuBtnClick(View v){
menu.getMenu().clear();
item.performConfigureMenu(menu.getMenu());
menu.show();
}
}

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