Compare commits

...

280 Commits

Author SHA1 Message Date
Grishka
88504531d4 Crash fixes 2022-05-05 22:05:18 +03:00
Grishka
4ad9fa030b Fix #127 2022-05-05 18:30:40 +03:00
Grishka
b4219bcaa0 Crash fix 2022-05-05 06:05:50 +03:00
Grishka
1a0435d32c Fix locale 2022-05-04 18:24:51 +03:00
Grishka
bd2a33da6a Fix 2022-05-04 18:10:04 +03:00
Grishka
84e8b08bff Merge remote-tracking branch 'origin/l10n_master' 2022-05-04 18:09:40 +03:00
Eugen Rochko
afc40cbb67 New translations strings.xml (Vietnamese) 2022-05-04 14:33:24 +02:00
Eugen Rochko
6902379af6 New translations strings.xml (Arabic) 2022-05-04 07:48:18 +02:00
Eugen Rochko
450bfa1fe8 New translations title.txt (Korean) 2022-05-04 07:48:16 +02:00
Eugen Rochko
629b1b4a34 New translations short_description.txt (Korean) 2022-05-04 07:48:15 +02:00
Eugen Rochko
8f49207b25 New translations full_description.txt (Korean) 2022-05-04 07:48:14 +02:00
Eugen Rochko
347d90ad14 New translations strings.xml (Korean) 2022-05-04 07:48:13 +02:00
Eugen Rochko
b503475fcf New translations strings.xml (Galician) 2022-05-04 06:49:01 +02:00
Eugen Rochko
fe18a43ba8 New translations strings.xml (Japanese) 2022-05-04 05:45:29 +02:00
Eugen Rochko
66e23bf55e New translations strings.xml (Chinese Traditional) 2022-05-04 02:49:50 +02:00
Eugen Rochko
716b6b13b7 New translations strings.xml (Russian) 2022-05-04 01:03:46 +02:00
Grishka
23ec3e64cf Add favorites/reblogs lists and extended footer for ThreadFragment
closes #41, closes #64
2022-05-04 01:04:59 +03:00
Grishka
e512a7ef90 Fix #52 2022-05-03 22:26:36 +03:00
Grishka
9823537474 Allow opening avatars and cover images in photo viewer
closes #24
2022-05-03 22:14:56 +03:00
Eugen Rochko
f9ea2b0de3 New translations strings.xml (Japanese) 2022-05-03 19:21:05 +02:00
Eugen Rochko
90293f81d9 New translations strings.xml (German) 2022-05-03 19:21:04 +02:00
Eugen Rochko
30785457b7 New translations strings.xml (Arabic) 2022-05-03 17:57:03 +02:00
Eugen Rochko
df5cb3d977 New translations strings.xml (Arabic) 2022-05-03 16:51:17 +02:00
Grishka
bbedf46b21 Accept URLs in instance search 2022-05-03 15:35:48 +03:00
Eugen Rochko
0d50f8c45b New translations strings.xml (Galician) 2022-05-03 07:17:13 +02:00
Grishka
d4e4d9fcde Use random IDs to match FCM notifications to accounts 2022-05-03 03:01:18 +03:00
Eugen Rochko
50381f1256 New translations strings.xml (Arabic) 2022-05-03 01:00:47 +02:00
Eugen Rochko
55a6b7bdd3 New translations strings.xml (Russian) 2022-05-03 01:00:46 +02:00
Eugen Rochko
c9eac418d2 New translations strings.xml (Italian) 2022-05-02 22:01:23 +02:00
Gregory K
fa75570254 Merge pull request #112 from sk22/fix/space-in-empty-reply
Fix compose containing a wrong space as initial reply text
2022-05-02 22:06:12 +03:00
Eugen Rochko
a51bcba87b New translations strings.xml (Italian) 2022-05-02 21:01:43 +02:00
sk
1406ea376d fix space as reply initial text 2022-05-02 20:59:22 +02:00
Eugen Rochko
4f4212124c New translations strings.xml (Thai) 2022-05-02 19:47:57 +02:00
Eugen Rochko
da773dfac9 New translations strings.xml (German) 2022-05-02 19:47:56 +02:00
Eugen Rochko
35185143a2 New translations strings.xml (Thai) 2022-05-02 18:38:07 +02:00
Eugen Rochko
f12a33a749 New translations strings.xml (Japanese) 2022-05-02 18:38:06 +02:00
Eugen Rochko
77a2a5a629 New translations strings.xml (Vietnamese) 2022-05-02 17:03:32 +02:00
Eugen Rochko
d09302492e New translations strings.xml (Thai) 2022-05-02 12:02:52 +02:00
Eugen Rochko
ab5895b21c New translations strings.xml (Thai) 2022-05-02 05:00:55 +02:00
Eugen Rochko
26360613b1 New translations strings.xml (Catalan) 2022-05-02 05:00:54 +02:00
Eugen Rochko
bd020f077f New translations strings.xml (German) 2022-05-02 05:00:53 +02:00
Eugen Rochko
35622f3675 New translations strings.xml (Greek) 2022-05-02 05:00:52 +02:00
Eugen Rochko
7516bdf2e8 New translations strings.xml (Basque) 2022-05-02 05:00:51 +02:00
Eugen Rochko
d07e765873 New translations strings.xml (Hebrew) 2022-05-02 05:00:50 +02:00
Eugen Rochko
510c97a552 New translations strings.xml (Japanese) 2022-05-02 05:00:49 +02:00
Eugen Rochko
6d78a43bfe New translations strings.xml (Korean) 2022-05-02 05:00:48 +02:00
Eugen Rochko
6ac880828e New translations strings.xml (Polish) 2022-05-02 05:00:47 +02:00
Eugen Rochko
2eb01ed477 New translations strings.xml (Portuguese) 2022-05-02 05:00:46 +02:00
Eugen Rochko
c32ca51fa5 New translations strings.xml (Russian) 2022-05-02 05:00:45 +02:00
Eugen Rochko
998e560835 New translations strings.xml (Swedish) 2022-05-02 05:00:44 +02:00
Eugen Rochko
46325f46c1 New translations strings.xml (Turkish) 2022-05-02 05:00:43 +02:00
Eugen Rochko
9f1d82ed12 New translations strings.xml (Spanish) 2022-05-02 05:00:42 +02:00
Eugen Rochko
24c5a2bf6c New translations strings.xml (Chinese Simplified) 2022-05-02 05:00:41 +02:00
Eugen Rochko
050de32cae New translations strings.xml (Portuguese, Brazilian) 2022-05-02 05:00:41 +02:00
Eugen Rochko
1d295ca058 New translations strings.xml (Bosnian) 2022-05-02 05:00:40 +02:00
Eugen Rochko
1779c132cd New translations strings.xml (Occitan) 2022-05-02 05:00:39 +02:00
Eugen Rochko
f70fcb8ff8 New translations strings.xml (Croatian) 2022-05-02 05:00:38 +02:00
Eugen Rochko
a4878b427e New translations strings.xml (Ukrainian) 2022-05-02 05:00:37 +02:00
Eugen Rochko
fcc73b5877 New translations strings.xml (Italian) 2022-05-02 05:00:36 +02:00
Eugen Rochko
8e5bf91a01 New translations strings.xml (Galician) 2022-05-02 05:00:35 +02:00
Eugen Rochko
4faa8cf7a8 New translations strings.xml (Vietnamese) 2022-05-02 05:00:34 +02:00
Eugen Rochko
afe8fd89e4 New translations strings.xml (Arabic) 2022-05-02 05:00:33 +02:00
Eugen Rochko
4df4528e60 New translations strings.xml (Armenian) 2022-05-02 05:00:32 +02:00
Eugen Rochko
ff99430f4c New translations strings.xml (Kabyle) 2022-05-02 05:00:31 +02:00
Eugen Rochko
f4026f09a0 New translations strings.xml (Finnish) 2022-05-02 05:00:30 +02:00
Eugen Rochko
ea9a2047f6 New translations strings.xml (Chinese Traditional) 2022-05-02 05:00:29 +02:00
Eugen Rochko
c16d373de8 New translations strings.xml (French) 2022-05-02 05:00:28 +02:00
Grishka
5562bf936e toot -> post 2022-05-02 05:49:59 +03:00
Grishka
02a1f2ef8c Add following/followers lists
closes #25
2022-05-02 05:45:51 +03:00
Grishka
7b26649521 Probably fix #95 2022-05-02 01:20:07 +03:00
Eugen Rochko
e34542a420 New translations strings.xml (Armenian) 2022-05-01 22:17:54 +02:00
Eugen Rochko
a0007f2e41 New translations strings.xml (Japanese) 2022-05-01 17:28:38 +02:00
Eugen Rochko
e5067e8982 New translations strings.xml (Italian) 2022-05-01 15:18:59 +02:00
Eugen Rochko
779d93b689 New translations strings.xml (Italian) 2022-05-01 14:12:57 +02:00
Eugen Rochko
397f67af10 New translations strings.xml (Arabic) 2022-05-01 12:40:14 +02:00
Eugen Rochko
027c4e0e59 New translations strings.xml (Thai) 2022-05-01 11:42:07 +02:00
Eugen Rochko
c04278754e New translations strings.xml (Chinese Simplified) 2022-05-01 11:42:06 +02:00
Eugen Rochko
daba0836e0 New translations strings.xml (Turkish) 2022-05-01 11:42:05 +02:00
Eugen Rochko
52307de614 New translations strings.xml (Thai) 2022-05-01 10:38:21 +02:00
Eugen Rochko
6f8ce04c48 New translations strings.xml (Arabic) 2022-05-01 00:43:40 +02:00
Eugen Rochko
144efdffee New translations strings.xml (Portuguese) 2022-04-30 23:48:17 +02:00
Grishka
8059120136 Better account switcher 2022-05-01 00:44:28 +03:00
Eugen Rochko
7819f10b8b New translations strings.xml (Portuguese) 2022-04-30 22:50:21 +02:00
Eugen Rochko
999c2e4714 New translations strings.xml (French) 2022-04-30 20:52:24 +02:00
Grishka
ec38210dde Fix #99 2022-04-30 20:30:29 +03:00
Eugen Rochko
1ebb5ad46d New translations full_description.txt (Russian) 2022-04-30 17:55:02 +02:00
Eugen Rochko
004d7a7652 New translations full_description.txt (Russian) 2022-04-30 16:57:21 +02:00
Eugen Rochko
6b68bd58f1 New translations strings.xml (Japanese) 2022-04-30 13:40:19 +02:00
Eugen Rochko
775ae68314 New translations strings.xml (Japanese) 2022-04-30 12:40:08 +02:00
Eugen Rochko
347a53f03f New translations strings.xml (Italian) 2022-04-30 08:29:46 +02:00
Eugen Rochko
690792ed0d New translations strings.xml (Arabic) 2022-04-30 07:26:39 +02:00
Eugen Rochko
6ee44edf84 New translations strings.xml (Galician) 2022-04-30 07:26:38 +02:00
Eugen Rochko
fc2ba241a0 New translations strings.xml (Arabic) 2022-04-30 06:28:06 +02:00
Eugen Rochko
a1efdd7e03 New translations strings.xml (Arabic) 2022-04-30 05:30:09 +02:00
Eugen Rochko
a79b0a4f15 New translations full_description.txt (Russian) 2022-04-30 00:25:43 +02:00
Eugen Rochko
69b4cf93a3 New translations strings.xml (Russian) 2022-04-30 00:25:41 +02:00
Eugen Rochko
e207b5929d New translations strings.xml (Thai) 2022-04-29 23:26:44 +02:00
Eugen Rochko
03e08dddf5 New translations strings.xml (Arabic) 2022-04-29 23:26:43 +02:00
Eugen Rochko
ad60646b8e New translations short_description.txt (Portuguese) 2022-04-29 23:26:41 +02:00
Eugen Rochko
51938f5522 New translations full_description.txt (Portuguese) 2022-04-29 23:26:40 +02:00
Grishka
38eadca4e2 Report fixes 2022-04-30 00:02:13 +03:00
Eugen Rochko
0fb1b3228f New translations strings.xml (Thai) 2022-04-29 22:10:13 +02:00
Eugen Rochko
5a8ebdb13b New translations strings.xml (Arabic) 2022-04-29 22:10:12 +02:00
Eugen Rochko
046a45a25e New translations strings.xml (Italian) 2022-04-29 22:10:10 +02:00
Eugen Rochko
751028326a New translations strings.xml (Thai) 2022-04-29 21:09:16 +02:00
Eugen Rochko
74e049884b New translations strings.xml (Italian) 2022-04-29 21:09:15 +02:00
Grishka
31cb17d549 Update readme 2022-04-29 20:39:19 +03:00
Eugen Rochko
3b63ca1b55 New translations strings.xml (Thai) 2022-04-29 18:05:59 +02:00
Eugen Rochko
3eed854909 New translations strings.xml (Thai) 2022-04-29 17:08:52 +02:00
Grishka
10a5bf0a82 Fix notification policy layout for long strings 2022-04-29 17:38:48 +03:00
Grishka
a58a279e8c Client-side filtering is annoying 2022-04-29 17:25:32 +03:00
Grishka
0fe58e49b6 Display more relationship states + locked accounts
closes #85, #73, #30
2022-04-29 17:11:23 +03:00
Eugen Rochko
e4b187acd6 New translations short_description.txt (Thai) 2022-04-29 15:49:50 +02:00
Eugen Rochko
9a95944adb New translations strings.xml (Thai) 2022-04-29 15:49:49 +02:00
Eugen Rochko
21e441d683 New translations strings.xml (Thai) 2022-04-29 14:48:02 +02:00
Grishka
089e297656 fix #93 2022-04-29 15:17:29 +03:00
Eugen Rochko
93906ecf08 New translations strings.xml (Armenian) 2022-04-29 13:50:46 +02:00
Eugen Rochko
cdb836742e New translations short_description.txt (Japanese) 2022-04-29 13:50:44 +02:00
Eugen Rochko
80cff031d7 New translations full_description.txt (Japanese) 2022-04-29 13:50:43 +02:00
Eugen Rochko
cea17b22cb New translations strings.xml (Japanese) 2022-04-29 13:50:42 +02:00
Eugen Rochko
97bf165e9e New translations strings.xml (Japanese) 2022-04-29 12:51:33 +02:00
Eugen Rochko
36345582c7 New translations strings.xml (Vietnamese) 2022-04-29 11:51:19 +02:00
Eugen Rochko
940a4a9ce7 New translations strings.xml (Turkish) 2022-04-29 11:51:18 +02:00
Eugen Rochko
8362bca6bf New translations short_description.txt (Chinese Traditional) 2022-04-29 09:03:12 +02:00
Eugen Rochko
09ef005d0e New translations strings.xml (Chinese Traditional) 2022-04-29 09:03:11 +02:00
Eugen Rochko
5ec1ec26b7 New translations strings.xml (Chinese Traditional) 2022-04-29 07:49:14 +02:00
Eugen Rochko
3ee159a4a5 New translations full_description.txt (Chinese Traditional) 2022-04-29 06:30:57 +02:00
Eugen Rochko
084b0d3a0c New translations strings.xml (Chinese Traditional) 2022-04-29 06:30:56 +02:00
Eugen Rochko
b5692c1ddc New translations short_description.txt (Chinese Traditional) 2022-04-29 05:14:54 +02:00
Eugen Rochko
e986a7f023 New translations full_description.txt (Chinese Traditional) 2022-04-29 05:14:53 +02:00
Eugen Rochko
367843d12b New translations strings.xml (Russian) 2022-04-29 03:34:59 +02:00
Eugen Rochko
40186b0025 New translations full_description.txt (Arabic) 2022-04-29 03:34:51 +02:00
Grishka
2a65bdb08f Add strings for follow states 2022-04-29 03:45:41 +03:00
Grishka
93fbc52f6a More crash fixes 2022-04-29 03:37:18 +03:00
Eugen Rochko
6df0333d97 New translations full_description.txt (Arabic) 2022-04-29 02:22:53 +02:00
Grishka
4e4b5fcfe4 Probably a crash fix 2022-04-29 03:06:06 +03:00
Eugen Rochko
11363d6dea New translations title.txt (Arabic) 2022-04-29 01:21:47 +02:00
Eugen Rochko
3e5d369004 New translations short_description.txt (Arabic) 2022-04-29 01:21:46 +02:00
Eugen Rochko
b3fd81ce26 New translations full_description.txt (Arabic) 2022-04-29 01:21:45 +02:00
Eugen Rochko
68d4eae53f New translations title.txt (Thai) 2022-04-28 23:27:23 +02:00
Eugen Rochko
01e8a9026b New translations short_description.txt (Thai) 2022-04-28 23:27:23 +02:00
Eugen Rochko
b0039926e5 New translations full_description.txt (Thai) 2022-04-28 23:27:22 +02:00
Eugen Rochko
86ec53c4dc New translations strings.xml (Thai) 2022-04-28 23:27:21 +02:00
Eugen Rochko
b5d57998ae New translations title.txt (Finnish) 2022-04-28 23:27:20 +02:00
Eugen Rochko
1c77c6308e New translations short_description.txt (Finnish) 2022-04-28 23:27:19 +02:00
Eugen Rochko
acee26a573 New translations full_description.txt (Finnish) 2022-04-28 23:27:18 +02:00
Eugen Rochko
4a5f20c073 New translations strings.xml (Finnish) 2022-04-28 23:27:17 +02:00
Eugen Rochko
cebef82c83 New translations strings.xml (Turkish) 2022-04-28 23:27:16 +02:00
Grishka
620bc2285c Fix notification settings getting reset on app update 2022-04-28 23:32:43 +03:00
Grishka
f73849dbb7 Home timeline auto-refresh
close #32
2022-04-28 23:22:55 +03:00
Eugen Rochko
2c12e8bc2f New translations full_description.txt (Vietnamese) 2022-04-28 15:26:58 +02:00
Eugen Rochko
4e0a0a5065 New translations strings.xml (Vietnamese) 2022-04-28 15:26:56 +02:00
Eugen Rochko
d80f6a1c2c New translations strings.xml (Vietnamese) 2022-04-28 14:16:15 +02:00
Eugen Rochko
8081d5fa1a New translations strings.xml (Italian) 2022-04-28 12:29:33 +02:00
Eugen Rochko
d7c56b52ac New translations strings.xml (Galician) 2022-04-28 07:27:41 +02:00
Grishka
e8eb12532a Fixes 2022-04-28 01:35:20 +03:00
Eugen Rochko
e95d0c9914 New translations strings.xml (Kabyle) 2022-04-28 00:18:10 +02:00
Eugen Rochko
f1fd12639e New translations strings.xml (Catalan) 2022-04-28 00:18:09 +02:00
Eugen Rochko
3009d7e6fa New translations strings.xml (German) 2022-04-28 00:18:08 +02:00
Eugen Rochko
2438dfde2a New translations strings.xml (Basque) 2022-04-28 00:18:06 +02:00
Eugen Rochko
28e8332b67 New translations strings.xml (Polish) 2022-04-28 00:18:03 +02:00
Eugen Rochko
29bd34ab2b New translations strings.xml (Russian) 2022-04-28 00:18:01 +02:00
Eugen Rochko
6f2e8237de New translations strings.xml (Spanish) 2022-04-28 00:17:59 +02:00
Eugen Rochko
e2308fcb5d New translations strings.xml (Turkish) 2022-04-28 00:17:58 +02:00
Eugen Rochko
8054084537 New translations strings.xml (Portuguese, Brazilian) 2022-04-28 00:17:57 +02:00
Eugen Rochko
ec8f2dbdf4 New translations strings.xml (Bosnian) 2022-04-28 00:17:56 +02:00
Eugen Rochko
80bc1d8339 New translations strings.xml (Croatian) 2022-04-28 00:17:54 +02:00
Eugen Rochko
c1b28bde6b New translations strings.xml (Italian) 2022-04-28 00:17:53 +02:00
Eugen Rochko
7c3b5c4a15 New translations strings.xml (Galician) 2022-04-28 00:17:51 +02:00
Eugen Rochko
eac0fdbcbf New translations strings.xml (Vietnamese) 2022-04-28 00:17:50 +02:00
Eugen Rochko
1a129ad684 New translations strings.xml (Arabic) 2022-04-28 00:17:50 +02:00
Eugen Rochko
583758b231 New translations strings.xml (Chinese Simplified) 2022-04-28 00:17:48 +02:00
Eugen Rochko
254bc8c0ab New translations strings.xml (French) 2022-04-28 00:17:47 +02:00
Grishka
9a0c383da8 Change community -> server 2022-04-28 00:54:35 +03:00
Eugen Rochko
97843d5ca1 New translations strings.xml (Arabic) 2022-04-27 22:41:22 +02:00
Eugen Rochko
f2eac28006 New translations title.txt (Arabic) 2022-04-27 21:44:24 +02:00
Eugen Rochko
d0eae2d17f New translations strings.xml (Arabic) 2022-04-27 21:44:23 +02:00
Eugen Rochko
6c4d9a1d0f New translations short_description.txt (Arabic) 2022-04-27 19:55:54 +02:00
Eugen Rochko
fc9e38ea24 New translations full_description.txt (Arabic) 2022-04-27 19:55:53 +02:00
Eugen Rochko
7c07d521f3 New translations strings.xml (Arabic) 2022-04-27 19:55:52 +02:00
Eugen Rochko
73d7c40cdd New translations strings.xml (Arabic) 2022-04-27 18:59:22 +02:00
Eugen Rochko
ba3871fc2d New translations strings.xml (Vietnamese) 2022-04-27 15:14:28 +02:00
Eugen Rochko
f1f14b765a New translations strings.xml (Arabic) 2022-04-27 13:34:07 +02:00
Eugen Rochko
034a4b501a New translations strings.xml (Arabic) 2022-04-27 00:29:03 +02:00
Eugen Rochko
2db10585d5 New translations strings.xml (Arabic) 2022-04-26 23:31:54 +02:00
Eugen Rochko
cb6bd4180b New translations strings.xml (Arabic) 2022-04-26 22:33:36 +02:00
Eugen Rochko
cd099fc17e New translations strings.xml (Arabic) 2022-04-26 21:30:07 +02:00
Eugen Rochko
8136a9af63 New translations strings.xml (Turkish) 2022-04-26 15:18:28 +02:00
Eugen Rochko
dd67d9d078 New translations strings.xml (German) 2022-04-26 13:41:03 +02:00
Eugen Rochko
3fcab4122c New translations strings.xml (Catalan) 2022-04-26 13:41:02 +02:00
Eugen Rochko
fac79bbeaa New translations strings.xml (Galician) 2022-04-26 12:39:28 +02:00
Eugen Rochko
c21061e0a7 New translations strings.xml (Galician) 2022-04-26 11:22:52 +02:00
Eugen Rochko
0f3421296d New translations strings.xml (Kabyle) 2022-04-26 08:31:40 +02:00
Eugen Rochko
c6a8bd96bc New translations strings.xml (Kabyle) 2022-04-26 06:44:29 +02:00
Eugen Rochko
9201760103 New translations strings.xml (Kabyle) 2022-04-26 05:34:01 +02:00
Eugen Rochko
f0c521ea95 New translations strings.xml (Kabyle) 2022-04-26 04:30:59 +02:00
Eugen Rochko
aceb89242e New translations strings.xml (Kabyle) 2022-04-26 03:17:29 +02:00
Eugen Rochko
4ff2f369f6 New translations short_description.txt (Kabyle) 2022-04-26 02:21:49 +02:00
Eugen Rochko
226ac8303c New translations full_description.txt (Kabyle) 2022-04-26 02:21:48 +02:00
Eugen Rochko
5601554051 New translations strings.xml (Kabyle) 2022-04-26 02:21:47 +02:00
Eugen Rochko
f06492de56 New translations title.txt (Kabyle) 2022-04-26 01:16:06 +02:00
Eugen Rochko
f70f2af973 New translations short_description.txt (Kabyle) 2022-04-26 01:16:05 +02:00
Eugen Rochko
321fc5aa25 New translations full_description.txt (Kabyle) 2022-04-26 01:16:04 +02:00
Eugen Rochko
178207026f New translations strings.xml (Kabyle) 2022-04-26 01:16:03 +02:00
Eugen Rochko
dcb96dafeb New translations title.txt (Armenian) 2022-04-26 01:16:02 +02:00
Eugen Rochko
6d807e967f New translations short_description.txt (Armenian) 2022-04-26 01:16:01 +02:00
Eugen Rochko
10df38d9b1 New translations full_description.txt (Armenian) 2022-04-26 01:16:00 +02:00
Eugen Rochko
69c0873c8f New translations strings.xml (Armenian) 2022-04-26 01:15:59 +02:00
Eugen Rochko
b04e328a53 New translations title.txt (Arabic) 2022-04-26 01:15:58 +02:00
Eugen Rochko
fb5afae720 New translations short_description.txt (Arabic) 2022-04-26 01:15:57 +02:00
Eugen Rochko
6875f40480 New translations full_description.txt (Arabic) 2022-04-26 01:15:57 +02:00
Eugen Rochko
1ed79d2355 New translations strings.xml (Arabic) 2022-04-26 01:15:56 +02:00
Eugen Rochko
287e5fc058 New translations strings.xml (Italian) 2022-04-26 01:15:55 +02:00
Grishka
ed79cebc57 Merge remote-tracking branch 'origin/l10n_master' 2022-04-26 00:58:50 +03:00
Grishka
8e65459cb5 bump version 2022-04-26 00:58:33 +03:00
Eugen Rochko
e32063fa09 New translations strings.xml (Vietnamese) 2022-04-25 22:26:35 +02:00
Eugen Rochko
d979389715 New translations strings.xml (Italian) 2022-04-25 22:26:19 +02:00
Eugen Rochko
d0f2af2913 New translations strings.xml (Galician) 2022-04-25 22:26:19 +02:00
Eugen Rochko
309ccc0a70 New translations strings.xml (Turkish) 2022-04-25 22:26:18 +02:00
Grishka
89c7a13c59 Add info banners in discover sub-tabs 2022-04-25 22:27:18 +03:00
Eugen Rochko
ea4d520e23 New translations strings.xml (Italian) 2022-04-25 21:24:49 +02:00
Eugen Rochko
d6a42d0d6b New translations strings.xml (Galician) 2022-04-25 18:35:42 +02:00
Eugen Rochko
025458ce8c New translations strings.xml (Chinese Simplified) 2022-04-25 06:31:05 +02:00
Eugen Rochko
650822f3b3 New translations short_description.txt (Chinese Simplified) 2022-04-25 05:20:18 +02:00
Eugen Rochko
0422a8c590 New translations full_description.txt (Chinese Simplified) 2022-04-25 05:20:17 +02:00
Eugen Rochko
b640f6c68d New translations strings.xml (Chinese Simplified) 2022-04-25 05:20:16 +02:00
Eugen Rochko
a4b89b8a52 New translations strings.xml (Turkish) 2022-04-24 19:39:46 +02:00
Grishka
3c9670bbaa Fix notification background 2022-04-24 19:33:57 +03:00
Eugen Rochko
22d27b13e7 New translations strings.xml (Vietnamese) 2022-04-24 15:37:22 +02:00
Eugen Rochko
233f87d90b New translations strings.xml (Portuguese, Brazilian) 2022-04-24 15:37:20 +02:00
Grishka
0dd5e5af8d Fix #71 2022-04-24 15:41:15 +03:00
Grishka
61d537779b Local timeline 2022-04-24 13:51:03 +03:00
Eugen Rochko
faa6ed336d New translations strings.xml (Italian) 2022-04-23 22:37:59 +02:00
Grishka
f499444a86 Fix pre-upload image resizing 2022-04-23 22:25:58 +03:00
Eugen Rochko
d6202d005f New translations strings.xml (Turkish) 2022-04-23 16:15:22 +02:00
Eugen Rochko
2df42396c3 New translations short_description.txt (Croatian) 2022-04-23 01:17:53 +02:00
Eugen Rochko
73002a8dbf New translations full_description.txt (Croatian) 2022-04-23 01:17:52 +02:00
Eugen Rochko
a50a14492f New translations strings.xml (Croatian) 2022-04-23 01:17:51 +02:00
Eugen Rochko
d896402d39 New translations full_description.txt (Croatian) 2022-04-23 00:13:04 +02:00
Eugen Rochko
571e593041 New translations full_description.txt (Croatian) 2022-04-22 22:45:29 +02:00
Eugen Rochko
b7a6d5313d New translations strings.xml (French) 2022-04-22 20:29:34 +02:00
Eugen Rochko
c6ea07e43e New translations full_description.txt (Croatian) 2022-04-22 19:27:27 +02:00
Eugen Rochko
1d33f476d6 New translations full_description.txt (Polish) 2022-04-22 19:27:26 +02:00
Eugen Rochko
9dca538f96 New translations strings.xml (Polish) 2022-04-22 19:27:25 +02:00
Eugen Rochko
823a6d7905 New translations full_description.txt (Croatian) 2022-04-22 18:31:47 +02:00
Eugen Rochko
4b12adc0f8 New translations strings.xml (Polish) 2022-04-22 18:31:46 +02:00
Eugen Rochko
1720665212 New translations strings.xml (Galician) 2022-04-22 16:28:34 +02:00
Eugen Rochko
83e6bb2ced New translations strings.xml (Turkish) 2022-04-22 15:31:26 +02:00
Eugen Rochko
78342fbe74 New translations strings.xml (German) 2022-04-22 13:24:00 +02:00
Eugen Rochko
0cb502b244 New translations full_description.txt (Croatian) 2022-04-22 13:23:48 +02:00
Grishka
559835d849 Crash fixes 2022-04-22 14:15:52 +03:00
Eugen Rochko
fd4854adae New translations strings.xml (Russian) 2022-04-22 11:59:26 +02:00
Eugen Rochko
2b7d9e5536 New translations strings.xml (Portuguese) 2022-04-22 11:59:25 +02:00
Eugen Rochko
15d73ab9a2 New translations strings.xml (Portuguese) 2022-04-22 11:03:22 +02:00
Eugen Rochko
05a7dd9636 New translations strings.xml (Vietnamese) 2022-04-22 05:27:52 +02:00
Eugen Rochko
899e016594 New translations strings.xml (Vietnamese) 2022-04-22 04:30:12 +02:00
Eugen Rochko
2b752be6d0 New translations strings.xml (Croatian) 2022-04-22 02:50:55 +02:00
Eugen Rochko
0dd42a07dc New translations full_description.txt (French) 2022-04-22 02:50:54 +02:00
Eugen Rochko
9e527d9ab1 New translations strings.xml (French) 2022-04-22 02:50:53 +02:00
Eugen Rochko
3ee0782a61 New translations strings.xml (Croatian) 2022-04-22 01:35:49 +02:00
Eugen Rochko
2721f43e23 New translations full_description.txt (French) 2022-04-22 01:35:48 +02:00
Eugen Rochko
622b76e27e New translations strings.xml (French) 2022-04-22 01:35:47 +02:00
Eugen Rochko
805b2120d6 New translations strings.xml (Croatian) 2022-04-22 00:39:41 +02:00
Eugen Rochko
161be10c4b New translations strings.xml (Croatian) 2022-04-21 23:39:09 +02:00
Eugen Rochko
95be6ece9d New translations strings.xml (Occitan) 2022-04-21 23:39:08 +02:00
Eugen Rochko
1e73ffdba8 New translations strings.xml (French) 2022-04-21 23:39:07 +02:00
Eugen Rochko
7b07fe0ead New translations short_description.txt (French) 2022-04-21 22:43:29 +02:00
Eugen Rochko
64c91c7df6 New translations full_description.txt (French) 2022-04-21 22:43:28 +02:00
Eugen Rochko
1a36440c9a New translations strings.xml (French) 2022-04-21 22:43:27 +02:00
Eugen Rochko
b3b9d848c9 New translations full_description.txt (Turkish) 2022-04-21 21:24:33 +02:00
Eugen Rochko
acb898ae1f New translations strings.xml (Turkish) 2022-04-21 21:24:32 +02:00
Eugen Rochko
2fd3240aca New translations full_description.txt (Galician) 2022-04-21 20:28:43 +02:00
Eugen Rochko
12945a9da2 New translations short_description.txt (Turkish) 2022-04-21 20:28:42 +02:00
Eugen Rochko
2e3fc22185 New translations full_description.txt (Turkish) 2022-04-21 20:28:41 +02:00
Eugen Rochko
9925c3ac75 New translations strings.xml (Turkish) 2022-04-21 20:28:40 +02:00
Eugen Rochko
30d767441f New translations full_description.txt (Galician) 2022-04-21 19:15:06 +02:00
Eugen Rochko
3df196181f New translations strings.xml (Basque) 2022-04-21 19:15:05 +02:00
Eugen Rochko
1ba447db33 New translations strings.xml (French) 2022-04-21 19:15:03 +02:00
Eugen Rochko
8b8fcfcd8c New translations strings.xml (French) 2022-04-21 18:18:39 +02:00
Eugen Rochko
6a1e7ef866 New translations strings.xml (Vietnamese) 2022-04-21 16:54:57 +02:00
157 changed files with 6141 additions and 511 deletions

View File

@@ -1,4 +1,5 @@
# Mastodon for Android
[![Crowdin](https://badges.crowdin.net/mastodon-for-android/localized.svg)](https://crowdin.com/project/mastodon-for-android)
<a href="https://play.google.com/store/apps/details?id=org.joinmastodon.android"><img src="img/google-play-badge.png" height="50"></a>

View File

@@ -0,0 +1,16 @@
ماستودون هي أكبر شبكة اجتماعية لا مركزيَّة على الإنترنت. بدلاً من كونها على موقع ويب واحد مركزي، هي عبارة عن شبكة من ملايين المستخدمين في مجتمعات مُستقلَّة يمكنهم جميعًا التفاعل مع بعضهم البعض بسلاسة. بغض النظر عن اهتماماتك، يمكنك مقابلة أشخاص متحمسين ينشرون عنها في ماستودون!
اِنضم إلَى مُجتَمع وأنشئ مِلَفَّكَ التَّعريفِيّ. ابحث عن أشخاص رائعين، تابعهم واقرأ منشوراتهم في خطٍّ زمني خالٍ من الإعلانات. عبِّر عَن نَفسِكَ باِستخدام رُموزٍ تَعبيرِيَّةٍ مُخصَّصَة، أو صُوَر، أو صُوَرٍ مُتحَرِّكَة، أو مَقاطِعٍ مَرئِّيَة أو مَقاطِعٍ صَوتِيَّةٍ فِي مَنشوراتٍ ذَاتُ خَمسِمائَة حَرف. رُدّ على سَلاسِلِ المَنشوراتِ، وأعِد تَدوينَ مَنشُوراتِ أيِّ شَخصٍ لِمُشارَكَةِ الأُمُورِ الرَّائِعَة. اِبحَث عَن حِساباتٍ جَديدَةٍ لِمُتابَعَتِها، وَعَن وُسُومٍ شَائِعَةٍ لِتَوسيعِ شَبَكَتِك.
ماستودون مبني بتركيز على الأمان والخصوصيَّة. حدِّد ما إذا أردتَ مُشارَكَةَ مَنشُوراتِكَ مَعَ مُتابِعيك، أو الأشخاصِ الَّذينَ أشَرتَ إليهِم فَقَط أو العالَمَ بأسرِه. تتيح لك تحذيرات المحتوى إخفاء المنشورات التي تحتوي على مواد حساسة أو محفِّزَة حتى تكون مستعد للتفاعل مع محتواها. لكل مجتمع إرشاداته الخاصة ومشرفيه الخاصين للحفاظ على أمان أعضائه، كما تُساعد أدوات الحظر والإبلاغ القوية في منع إساءة الاستخدام.
مَزيدٌ مِنَ المَزايَا:
• النمط الداكِن: قراءة المنشورات في النمط المضيء، الداكِن أو الأسود الحقيقي
• استطلاعات الرأي: اسأل المُتابعين عن آرائِهِم وسَتُسجَّل الأصوات
• الاستكشاف: الأوسِمَة والحِسابات الرائجة على بُعد نقرة واحِدَة
• الإشعارات: احصل على الجديد بشأن المُتابعات، الرُدود وعمليات إعادة التدوين
• المشاركة: انشر مباشرة على ماستودون من أي لوح مُشاركة في أي تطبيق
• الجاذبية: جالب الحظ لدينا هو فيل رائع، سَتراه يظهر فجأة في السطح بين الفينة والأُخرى
مَاستودُون هي مُنَظَّمَةُ غَيرُ رِبحِيَّةٍ مُسَجَّلَة. مُساهَمَاتُكَ هِي الدَّاعِمُ المُباشِرُ لعَمَلِيَّةِ التَّطوير. لا توجد إعلانات، لا تسييل ولا رأس مال استثماري، نحن نخطط للبقاء على هذا النحو.

View File

@@ -0,0 +1 @@
شَبَكةٌ اِجتِماعِيَّةٌ لَا مَركزِيَّة

View File

@@ -0,0 +1 @@
مَاستودُون

View File

@@ -0,0 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
More features:
• Dark Mode: Read posts in light, dark, or true black mode
• Polls: Ask followers for their opinion and tally the votes
• Explore: Trending hashtags and accounts are a tap away
• Notifications: Get notified about new follows, replies, and reblogs
• Sharing: Post directly to Mastodon from any share sheet in any app
• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.

View File

@@ -0,0 +1 @@
Decentralized social network

View File

@@ -0,0 +1 @@
Mastodon

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodon est le plus grand réseau social décentralisé sur Internet. Au lieu dun site Web unique, cest un réseau de millions dutilisateurs dans des communautés indépendantes qui peuvent tous interagir les uns avec les autres, de manière transparente. Peu importe ce que vous êtes, vous pouvez rencontrer des gens passionnés qui publient à ce sujet sur Mastodon !
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Rejoignez une communauté et créez votre profil. Trouvez et suivez des gens fascinants et lisez leurs messages dans une chronologie chronologique sans publicité. Exprimez-vous avec des émojis personnalisés, des images, des GIFs, des vidéos et de laudio dans des messages de 500 caractères. Répondez aux sujets de discussions et aux reblogues de nimporte qui pour partager des choses géniales. Trouvez de nouveaux comptes à suivre et des hashtags tendance pour étendre votre réseau.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon est construit en mettant laccent sur la vie privée et la sécurité. Décidez si vos messages sont partagés avec vos abonnés, les personnes que vous mentionnez, ou le monde entier. Les avertissements de contenu vous permettent de masquer les messages au contenu sensible jusquà ce que vous soyez prêt à vous engager avec eux. Chaque communauté a ses propres directives et modérateurs pour assurer la sécurité de ses membres, et de solides outils de blocage et de signalement aident à prévenir les abus.
More features:
Plus de fonctionnalités :
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
• Explore: Trending hashtags and accounts are a tap away
• Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Mode Sombre : Lisez les messages en mode clair, sombre ou vrai noir
Sondages : Demandez lopinion à vos abonnés et comptez les votes
• Explorer : Les hashtags et les comptes tendance sont à portée de main
• Notifications : Soyez informé des nouveaux abonnements, réponses et reblogs
Partage : Publiez directement sur Mastodon à partir de nimporte quelle feuille de partage dans nimporte quelle application
• Cuteness : Notre mascotte est un adorable éléphant, et vous la verrez apparaître de temps en temps
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon est un organisme sans but lucratif enregistré et le développement est soutenu directement par vos dons. Il ny a pas de publicité, pas de monétisation, pas de capital-risque, et nous prévoyons de continuer ainsi.

View File

@@ -1 +1 @@
Decentralized social network
Réseau social décentralisé

View File

@@ -1,16 +1,16 @@
Mastodon é a rede social descentralizada máis grande de internet. Non é unha soa web, é unha rede de millóns de persoas en comunidades independentes que poden interactuar entre elas, sen problema. Sexan cales fosen os teus intereses, podes atopar persoas comentando ese tema en Mastodon!
Únete a unha comunidade e crea un perfil. Atopa e segue a persoas interesantes e lé o que publican, nunha cronoloxía limpa de publicidade e ordenada. Exprésate usando emojis personalizados, imaxes, GIFs, vídeos e audio con publicacións de 500 caracteres. Responde aos fíos e promove publicacións doutras persoas que creas relevantes. Atopa novas contas e segue os cancelos en voga para facer medrar a túa rede.
Únete a unha comunidade e crea un perfil. Atopa e segue a persoas interesantes e le o que publican, nunha cronoloxía limpa de publicidade e ordenada. Exprésate usando emojis personalizados, imaxes, GIFs, vídeos e audio con publicacións de 500 caracteres. Responde aos fíos e promove publicacións doutras persoas que creas relevantes. Atopa novas contas e segue os cancelos en voga para facer medrar a túa rede.
Mastodon está creado pensando na privacidade e seguridade. Decide con quen compartes as túas publicacións, só coas seguidoras ou persoas que mencionas, ou con todo o mundo. Os avisos sobre o contido permiten agochar contido sensible ou material que podería crear ansiedade ata que estás preparada para velo. Cada comunidade ten as súas normas e a moderación coida da seguridade das persoas da instancia, con ferramentas para denunciar e bloquear e así evitar abusos.
Máis características:
• Modo Escuro: ler publicacións en modo claro, escuro ou negro total
• Modo Escuro: ler publicacións en modo claro, escuro ou negro verdadeiro
• Enquisas: pregúntalle ás seguidoras a súa opinión e recolle os votos
• Explorar: Cancelos e contas en voga fácilmente accesibles
• Explorar: Cancelos e contas en voga facilmente accesibles
• Notificacións: recibe notificacións sobre seguimentos, respostas e promocións
• Compartir: publica directamente en Mastodon desde o menú de calquera app
• Fermosa: A nosa mascota é un elefante moi feitiño, que verás a miúdo por aquí
• Fermosura: a nosa mascota é un elefante moi feitiño, que verás a miúdo por aquí
Mastodon é unha organización rexistrada sen ánimo de lucro cuxo desenvolvemento está financiado por doazóns. Non hai publicidade, nen monetización, sen inversións de capital risco, e pretendemos seguir así.

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodon je najveća decentralizirana društvena mreža na internetu. Umjesto jedne web stranice, to je mreža milijuna korisnika u neovisnim zajednicama koje sve mogu međusobno komunicirati. Bez obzira na to što te zanima, možeš upoznati strastvene ljude koji o tome objavljuju na Mastodonu!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Pridruži se zajednici i kreiraj svoj profil. Pronađi i prati fascinantne ljude i čitaj njihove postove u kronološkoj vremenskoj liniji bez oglasa. Izrazi se prilagođenim emojijima, slikama, GIF-ovima, videozapisima i zvukom u objavama od 500 znakova. Odgovori na teme i reblogaj postove od bilo koga da podijeliš sjajne stvari. Pronađi nove račune koje ćeš pratiti i popularne hashtagove kako bi proširio/la svoju mrežu.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon je izgrađen s fokusom na privatnost i sigurnost. Odluči hoće li se tvoje objave dijeliti s tvojim sljedbenicima, samo s osobama koje spominjete ili s cijelim svijetom. Upozorenja o sadržaju omogućuju ti da sakriješ postove koji sadrže osjetljivi ili izazovni materijal dok ne budeš spreman za interakciju s njima. Svaka zajednica ima vlastite smjernice i moderatore kako bi zaštitili svoje članove, a robusni alati za blokiranje i prijavljivanje pomažu u sprječavanju zlouporabe.
More features:
Više značajki:
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Tamni način rada: čitaj postove u svijetlom, tamnom ili stvarno crnom načinu
Ankete: Pitaj sljedbenike za mišljenje i zbroji glasove
Istraži: popularni hashtagovi i računi udaljeni su samo jedan dodir
Obavijesti: primaj obavijesti o novim pratiteljima, odgovorima i reblogovima
Dijeljenje: objavi izravno na Mastodonu s bilo kojeg lista za dijeljenje u bilo kojoj aplikaciji
Slatkoća: Naša maskota je ljupki slon i vidjet ćeš ih kako iskaču s vremena na vrijeme
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon je registrirana neprofitna organizacija i razvoj je podržan izravno vašim donacijama. Nema oglašavanja, nema monetizacije i rizičnog kapitala, a planiramo takvi i ostati.

View File

@@ -1 +1 @@
Decentralized social network
Decentralizirana društvena mreža

View File

@@ -0,0 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
More features:
• Dark Mode: Read posts in light, dark, or true black mode
• Polls: Ask followers for their opinion and tally the votes
• Explore: Trending hashtags and accounts are a tap away
• Notifications: Get notified about new follows, replies, and reblogs
• Sharing: Post directly to Mastodon from any share sheet in any app
• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.

View File

@@ -0,0 +1 @@
Decentralized social network

View File

@@ -0,0 +1 @@
Mastodon

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodonは、インターネット上で最大の分散型ソーシャルネットワークです。 Mastodonは単一のウェブサイトではなく、それぞれ独立したコミュニティに参加している何百万人ものユーザーによって構成されたネットワークなのです。ユーザーたちはその中で、誰もがお互いとシームレスにやり取りできます。 あなたの興味関心がどんな分野にあっても、きっとMastodonのどこかで同じ情熱を投稿している仲間がいますよ
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
まずはコミュニティに参加して、自分のプロフィールを作成しましょう。 そして素敵なユーザーを見つけて、フォローして、タイムラインで投稿を見てみましょう。タイムラインには広告なんてありませんし、順番も時系列順ですのでご安心を。 あるいは、500文字まで使える投稿で自分を表現してみましょう。カスタム絵文字や画像、GIF、動画、音声も使用できます。 スレッドに返事したり、他の誰かの面白い投稿をブーストして共有したりすることもできます。 新しいアカウントとホットなタグを見つけて、あなた自身のネットワークを広げていきましょう!
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastondonはプライバシーと安全性を重視しています。 自分の投稿をフォロワー限定公開にするのか、メンションした特定のユーザーにだけ共有するのか、全世界に大放流するのかは、すべてあなた次第。 また、入力中の投稿について「ちょっとセンシティブな内容だな」と思ったら、閲覧注意機能で内容を伏せることで、見たくない人に配慮した投稿が作成できます。 そして、各コミュニティにはそれぞれのガイドラインと管理者・モデレーターが存在し、コミュニティメンバーの安全を守っています。強力なブロック・通報機能も、不正利用の防止をお手伝いします。
More features:
その他の機能:
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
ダークモード対応:ライトモードだけでなく、ダークモードや「真っ黒」モードで投稿を閲覧
投票機能:フォロワーたちの意見を投票形式で集計
探索話題のハッシュタグやアカウントに1タップでアクセス
通知設定:新しいフォローやリプライ、ブーストがあった時に通知
共有どのアプリからでも、「共有」メニューを通じてMastodonへ直接投稿
癒しMastodonが誇る象のマスコットかわいいが、画面にお邪魔したり、しなかったり
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodonは公認の非営利アプリです。開発は全てユーザーの寄付から成り立っています。 広告なし、アフィリエイトなし、第三者組織による出資なし。今でも、そしてこれからもそんなアプリであり続けるために、我々は日々努力し続けています。

View File

@@ -1 +1 @@
Decentralized social network
分散型ソーシャルネットワーク

View File

@@ -0,0 +1,16 @@
Mastodon d azeṭṭa anmetti asrummsan meqqren deg internet. Ideg ara yili d asmel web asuf, d azeṭṭa n yimelyan n yiseqdacen deg temɣiwin tilelliyin i izemren ad myigwent gar-asent, s wudem afrawan. Akken ibɣu yili usentel i tḥemmleḍ, tzemreḍ ad temlileḍ imdanen i d-isuffuɣen ɣef usentel-nni ɣef Mastodon!
Rnu ɣer temɣiwent syen snulfu-d amaɣnu-inek. Af, rnu ḍfer imdanen yelhan. Teɣreḍ tisuffaɣ-nsen deg yizirig n wakud war adellel. Mmel iḥulfan-ik s yimujiten, tugniwin, GIFs, tividyutin d yimeslawen udmawanen deg tsuffaɣ n 500 yisekkilen. Ttekki deg usqerdec, talseḍ asuffeɣ n tsuffaɣ n yimdanen i beṭṭu n taktiwin igerrzen. Af imiḍanen ara tḍefreḍ akked hashtags mucaεen i wakken ad tesnerniḍ azeṭṭa-inek.
Mastodon yettwabna s tikci n wazal i tbaḍnit d tɣellist. Gzem-itt deg ṛṛay ma yella tisuffaɣ-inek·inem ad ttwabḍunt akked yineḍfaren-ik·im, akked yimdanen kan i d-tbedreḍ neɣ akked yimdanen meṛṛa. Ilɣa n ugbur ad ak·akem-yeǧǧ d teffreḍ tisuffaɣ ideg yella ugbur amḥalfu neɣ yir agbur alamma d asmi ara twejdeḍ ad tkecmeḍ ɣer-sen. Yal tamɣiwent ɣur-s ilugan-ines d yiseɣyaden-is i wakken ad teḍmentaɣellist n yiεeggalen-is, akked yifecka iǧehden i usewḥel d tummla n yineqqisen mgal yir aseqdec.
Ugar n temahilin:
• Askar aberkan: Γeṛ tisuffaɣ deg uskar aceεlal, aberkan neɣ aberkan aḥeqqani
• Isenqaden: Ssuter ṛṛay n yineḍfaren syen smiḍen afran
• Snirem: Hashtags d yimiḍanen mucaεen llan ɣef wafus
• Ilɣa: Ṭṭef ilɣa ɣef yineḍfaren, tiririyin d wallus n usuffeɣ imaynuten
• Beṭṭu: Azen srid ɣer Mastodon seg kra n tferkit n beṭṭu deg kra n usnas
• Ucbiḥ: Lfal-nneɣ d ilu icebḥen aṭas, ad t-tetttwaliḍ yettban-d sya ɣer da
Mastodon d takebbanit ur nettnadi ara ɣef tedrimt, asnerni-ines yettili-d s tewsa-nni i as-tettmuddum. Ulac adellel, ur njemmeε tadrimt, ur nesεi win aɣ-d-yettakken tadrimt. Akka i nettxemmim ad nkemmel abrid-nneɣ.

View File

@@ -0,0 +1 @@
Azeṭṭa anmetti asrummsan

View File

@@ -0,0 +1 @@
Mastodon

View File

@@ -4,9 +4,9 @@ Join a community and create your profile. Find and and follow fascinating folks
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
More features:
더 많은 기능:
Dark Mode: Read posts in light, dark, or true black mode
다크모드: 게시물을 밝음, 어두움, 진정한 검정 모드에서 읽으세요
• Polls: Ask followers for their opinion and tally the votes
• Explore: Trending hashtags and accounts are a tap away
• Notifications: Get notified about new follows, replies, and reblogs

View File

@@ -1 +1 @@
Decentralized social network
분산화된 소셜 네트워크

View File

@@ -1 +1 @@
Mastodon
마스토돈

View File

@@ -1,8 +1,8 @@
Mastodon to największa zdecentralizowana sieć społecznościowa w Internecie. Zamiast jednej strony internetowej, jest to sieć milionów użytkowników w niezależnych społecznościach, które mogą ze sobą wchodzić w interakcje. Niezależnie od swoich zainteresowań, momżesz poznać interesujących ludzi piszących o nich na Mastodonie!
Dołącz do społeczności i utwórz swój profil. Poznaj i obserwuj fascynujących ludzi i czytaj ich wpisy w chronologicznym osi czasu. Wyrażaj siebie za pomocą niestandardowych emoji, obrazów, GIFów, filmów i audio w 500-znakowych wpisach. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Dołącz do społeczności i utwórz swój profil. Poznaj i obserwuj fascynujących ludzi i czytaj ich wpisy w chronologicznym osi czasu. Wyrażaj siebie za pomocą niestandardowych emoji, obrazów, GIFów, filmów i audio w 500-znakowych wpisach. Odpowiadaj na wątki i podawaj dalej posty od każdego, aby dzielić się wspaniałymi rzeczami. Find new accounts to follow and trending hashtags to expand your network.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon został zaprojektowany z myślą o prywatności i bezpieczeństwie. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Ostrzeżenia dotyczące zawartości pozwalają ukrywać posty zawierające wrażliwe lub wyzywające materiały, dopóki nie będziesz gotów się z nimi pogodzić. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Więcej funkcji:

View File

@@ -1,4 +1,4 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
O Mastodon é a maior rede social descentralizada da Internet. Em vez de ser um único site, é uma rede de milhões de utilizadores em comunidades independentes que podem facilmente interagir uns com os outros. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.

View File

@@ -1 +1 @@
Decentralized social network
Rede social descentralizada

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodon — это крупнейшая распределённая социальная сеть в интернете. Вместо одного сайта, это сеть из независимых сообществ с миллионами пользователей, которые могут бесшовно взаимодействовать друг с другом. Вне зависимости от того, чем вы увлекаетесь, вы всегда найдёте себе единомышленников в Mastodon!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Вступите в сообщество по интересу и создайте свой профиль. Ищите и подписывайтесь на увлекательных пользователей, читайте их посты без рекламы в хронологической ленте. Выражайте себя в 500-символьных постах, дополняя их пользовательскими эмодзи, изображениями, гифками, видео и аудио. Участвуйте в обсуждениях и продвигайте отличные посты от других людей. Расширяйте свой кругозор, находя новых интересных людей и следя за актуальными хэштегами.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon создан с акцентом на конфиденциальность и безопасность. Решайте с кем вы хотите поделиться своими постами: своими подписчиками, только упомянутыми людьми или же вообще со всем миром. Предупреждения о содержимом позволят вам скрыть посты содержащие материалы деликатного или шокирующего характера. В каждом сообществе свои правила и модераторы, следящие за порядком, а надёжные инструменты блокировки и система жалоб помогают предотвращать злоупотребление.
More features:
Ещё больше возможностей:
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Темы на любой вкус: читайте посты в светлом, тёмном или OLED режимах
Спрашивайте мнение подписчиков и подсчитывайте их голоса с опросами
Найдите актуальные хэштеги, интересные посты и профили во вкладке «Обзор»
Будьте в курсе происходящего с уведомлениями о новых подписчиках, ответах и продвижениях
Делитесь в Mastodon содержимым из любого приложения
Умиляйтесь с нашим талисманом, восхитительным слонёнком, которого можно встретить и тут и там
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon является зарегистрированной некоммерческой организацией, его разработка поддерживается непосредственно вашими пожертвованиями. У нас нет рекламы, монетизации и венчурного капитала, и мы не планируем это менять.

View File

@@ -0,0 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
More features:
• Dark Mode: Read posts in light, dark, or true black mode
• Polls: Ask followers for their opinion and tally the votes
• Explore: Trending hashtags and accounts are a tap away
• Notifications: Get notified about new follows, replies, and reblogs
• Sharing: Post directly to Mastodon from any share sheet in any app
• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.

View File

@@ -0,0 +1 @@
เครือข่ายสังคมแบบกระจายศูนย์

View File

@@ -0,0 +1 @@
Mastodon

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodon, internetteki merkezi olmayan en büyük sosyal ağdır. Tek bir web siteye bağlı kalmaksızın, milyonlarca kullanıcının bağımsız olarak birbiri ile kolayca etkileşebileceği bir ağdır. Hangi konuyla ilgili olduğun önemli değil, Mastodon'da onunla ilgili gönderi paylaşan tutkulu insanlarla tanışabilirsin!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Bir topluluğa katıl ve profilini oluştur. Olağanüstü kişileri bul ve takip et, gönderilerini kronolojik ve reklamsız sunan bir akışta oku. Gönderilerinde 500 karakter sınırlamasıyla kendini emojiler, görseller, GIFler, videolar ve sesler ile ifade et. Harika içerikler paylaşmak için başlıklara yanıt yaz, insanların gönderilerini yeniden paylaş. Ağınızı genişletmek için takip edilecek yeni hesaplar ve hashtagler bul.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon gizlilik ve güvenlik odaklı yapılmıştır. Her postunuz için takipçilerinizle mi, bahsettiğiniz kişilerle mi ya da tüm dünyayla mı paylaşılacağına karar verin. Gönderi uyarıları, hassas ve tetikleyici olabilecek içerikleri kişi görmeyi hazır olana kadar gizler. Her topluluk, üyelerini güvende tutmak için kendi kurallarına ve moderatörlerine; istismarı önlemek için de güçlü engelleme ve bildirme araçlarına sahiptir.
More features:
Diğer özellikler:
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Koyu Mod: Gönderileri aydınlık, karanlık ya da gerçek karanlık modunda okuyabilirsin
Anketler: Takipçilerine görüşlerini sor ve oylarını gör
Keşfet: Trend hashtagler ve hesaplar bir tık uzağında
Bildirimler: Yeni takipçilerden, yanıtlardan ve yeniden paylaşımlardan haberin olsun
Paylaşım: Doğrudan Mastodon'a herhangi bir tipte gönderi paylaş
Sevimlilik: Maskotumuz şirin bir fil ve onu uygulamada zaman zaman göreceksin
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon kar amacı gütmeyen bir kuruluştur ve geliştirilmesi doğrudan bağışlarınızla sağlanmaktadır. Reklam, para kazanma amacı, risk sermayesi yoktur ve bunu böyle tutmayı planlıyoruz.

View File

@@ -1 +1 @@
Decentralized social network
Merkezsizleştirilmiş sosyal

View File

@@ -1,8 +1,8 @@
Mastodon là mạng xã hội liên hợp lớn nhất trên internet. Thay vì một trang web duy nhất, nó là một mạng lưới hàng triệu người dùng trong các cộng đồng độc lập, tất cả đều có thể tương tác với nhau một cách liền mạch. Bất kể bạn thích gì, bạn đều có thể gặp gỡ những người đăng tút về nó trên Mastodon!
Mastodon là mạng xã hội liên hợp lớn nhất trên internet. Thay vì một trang web duy nhất, nó là một mạng lưới hàng triệu người dùng trong các máy chủ độc lập, tất cả đều có thể tương tác với nhau một cách liền mạch. Bất kể bạn thích gì, bạn đều có thể gặp gỡ những người đăng tút về nó trên Mastodon!
Tham gia một cộng đồng và tạo trang hồ sơ của bạn. Tìm, theo dõi những người thú vị và đọc tút của họ theo trình tự thời gian, không có quảng cáo. Thể hiện bản thân bằng emoji, hình ảnh, GIF, video và âm thanh trong tút tối đa 500 ký tự. Trả lời tút và đăng lại tút từ bất kỳ ai để chia sẻ những điều tuyệt vời. Tìm những người dùng mới để theo dõi và các hashtag xu hướng để mở rộng mạng lưới của bạn.
Tham gia một máy chủ và tạo trang hồ sơ của bạn. Tìm, theo dõi những người thú vị và đọc tút của họ theo trình tự thời gian, không có quảng cáo. Thể hiện bản thân bằng emoji, hình ảnh, GIF, video và âm thanh trong tút tối đa 500 ký tự. Trả lời tút và đăng lại tút từ bất kỳ ai để chia sẻ những điều tuyệt vời. Tìm những người dùng mới để theo dõi và các hashtag xu hướng để mở rộng mạng lưới của bạn.
Mastodon được xây dựng tập trung vào sự riêng tư và an toàn. Quyết định xem tút của bạn được chia sẻ với những người theo dõi, chỉ những người bạn nhắc đến hay cả thế giới. Nội dung ẩn cho phép bạn ẩn các tút chứa nội dung nhạy cảm hoặc chơi chữ cho đến khi bạn sẵn sàng tương tác với chúng. Mỗi cộng đồng có các nguyên tắc riêng và kiểm duyệt viên riêng để giữ an toàn cho các thành viên, song song với các công cụ chặn và báo cáo mạnh mẽ giúp ngăn chặn hành vi bậy.
Mastodon được xây dựng tập trung vào sự riêng tư và an toàn. Quyết định xem tút của bạn được chia sẻ với những người theo dõi, chỉ những người bạn nhắc đến hay cả thế giới. Nội dung ẩn cho phép bạn ẩn các tút chứa nội dung nhạy cảm hoặc chơi chữ cho đến khi bạn sẵn sàng tương tác với chúng. Mỗi máy chủ có các nguyên tắc riêng và kiểm duyệt viên riêng để giữ an toàn cho các thành viên, song song với các công cụ chặn và báo cáo mạnh mẽ giúp ngăn chặn hành vi bậy.
Tính năng khác:

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodon 是互联网上最大的去中心化社交网络。 它不是一个网站,而是由独立社区节点及其数以百万计的用户组成的网络,所有这些用户都能够无缝地相互交流。 无论你进入哪一个节点,你都可以与所有在 Mastodon 的人之间进行交流。
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
加入一个社区节点并创建你的账户。 寻找并关注感兴趣的同好,无广告地浏览他们的时间线。 用自定义表情、图像、GIF、视频和音频在 500 个字符的帖子里写下你的想法。 回复及转发其他人的帖子来共同分享美好的事物。 寻找新的账户以及热门的话题来拓展你的网络。
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon 以隐私和安全为首要目标。 自由选择你的帖子是分享给关注者,或是你提到的人,亦或是整个世界。 内容警告允许让你隐藏可能剧透的内容,让其他人在交互之前做好充分准备。 每个社区都有自己的规则和管理员,让其用户保持安全。通过强有力的屏蔽和举报工具防止滥用。
More features:
更多功能:
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
暗色模式:以浅色、深色、纯黑模式下进行阅读
投票:询问关注者的意见并统计他们的投票
探索:热门的话题以及账号
通知:获得新关注、回复和转发的通知
分享:从其他应用中的分享菜单中直接发布到 Mastodon
吉祥物:你会不时地看到我们可爱的长毛象
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon 是一个注册的非营利开发项目,直接由您的捐赠支持。 没有广告,没有商业化,也没有风险资本,我们计划保持这种方式。

View File

@@ -1 +1 @@
Decentralized social network
分布式社交网络

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodon 是網際網路上最大的去中心化社交網路。 它是一個由能無縫互動的獨立社群中,數百萬使用者組成的網路,而非單一網站。 無論您對什麼事情感興趣,您都能在 Mastodon 上遇到充滿熱情的人們討論該話題。
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
加入社群並建立您的個人檔案。 尋找並追蹤迷人的夥伴,並在無廣告、按時間順序排列的時間軸上閱讀他們的貼文。 在 500 個字元的貼文中使用自訂表情符號、GIF、視訊與音訊來表達您自己。 回覆任何人的話題與轉發貼文以分享精彩內容。 尋找要追蹤的新帳號與熱門主題標籤來拓展您的網路。
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon 以隱私與安全為要。 決定您的貼文要與您的追蹤者分享、只與您提及的人們分享,又或是與全世界分享。 內容警告可讓您隱藏包含敏感或可能觸發強烈情緒反應的貼文,直到您準備好與它們進行互動。 每個社群都有它們自己的指導方針與管理原來確保其成員安全,強大的封鎖與回報工具有助於防止濫用。
More features:
更多功能:
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
深色模式:以淺色、深色或純黑色模式閱讀貼文
投票:詢問追蹤的意見並計票
探索:僅需輕點一下,即可看到熱門主題標籤與帳號
通知:取得關於新追蹤、回覆與轉發的通知
分享:從任何應用程式中的分享表中直接發表貼文到 Mastodon 中
可愛:我們的吉祥物是一隻可愛的大象,您會不時看到牠出現
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon 是一家註冊的非營利組織,您的捐款會直接支援開發工作。 沒有廣告、沒有貨幣化、沒有風險投資,我們計畫維持這種狀態。

View File

@@ -1 +1 @@
Decentralized social network
去中心化社群網路

View File

@@ -9,8 +9,8 @@ android {
applicationId "org.joinmastodon.android"
minSdk 23
targetSdk 31
versionCode 30
versionName "1.0.1"
versionCode 37
versionName "1.1.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -58,7 +58,7 @@ dependencies {
implementation 'me.grishka.litex:dynamicanimation:1.1.0-alpha03'
implementation 'me.grishka.litex:viewpager:1.0.0'
implementation 'me.grishka.litex:viewpager2:1.0.0'
implementation 'me.grishka.appkit:appkit:1.2.2'
implementation 'me.grishka.appkit:appkit:1.2.6'
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

@@ -18,6 +18,7 @@ import android.util.Log;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.requests.notifications.GetNotificationByID;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.PushNotification;
@@ -52,10 +53,23 @@ public class PushNotificationReceiver extends BroadcastReceiver{
String k=intent.getStringExtra("k");
String p=intent.getStringExtra("p");
String s=intent.getStringExtra("s");
String accountID=intent.getStringExtra("x");
if(!TextUtils.isEmpty(accountID) && !TextUtils.isEmpty(k) && !TextUtils.isEmpty(p) && !TextUtils.isEmpty(s)){
String pushAccountID=intent.getStringExtra("x");
if(!TextUtils.isEmpty(pushAccountID) && !TextUtils.isEmpty(k) && !TextUtils.isEmpty(p) && !TextUtils.isEmpty(s)){
MastodonAPIController.runInBackground(()->{
try{
List<AccountSession> accounts=AccountSessionManager.getInstance().getLoggedInAccounts();
AccountSession account=null;
for(AccountSession acc:accounts){
if(pushAccountID.equals(acc.pushAccountID)){
account=acc;
break;
}
}
if(account==null){
Log.w(TAG, "onReceive: account for id '"+pushAccountID+"' not found");
return;
}
String accountID=account.getID();
PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s);
new GetNotificationByID(pn.notificationId+"")
.setCallback(new Callback<>(){

View File

@@ -14,11 +14,13 @@ import org.joinmastodon.android.MastodonApp;
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.Filter;
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.utils.StatusFilterPredicate;
import java.io.IOException;
import java.util.ArrayList;
@@ -41,6 +43,8 @@ public class CacheController{
private DatabaseHelper db;
private final Runnable databaseCloseRunnable=this::closeDatabase;
private static final int POST_FLAG_GAP_AFTER=1;
static{
databaseThread.start();
}
@@ -49,14 +53,14 @@ public class CacheController{
this.accountID=accountID;
}
public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback<PaginatedResponse<List<Status>>> callback){
public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback<CacheablePaginatedResponse<List<Status>>> callback){
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
if(!forceReload){
SQLiteDatabase db=getOrOpenDatabase();
try(Cursor cursor=db.query("home_timeline", new String[]{"json"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){
try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){
if(cursor.getCount()==count){
ArrayList<Status> result=new ArrayList<>();
cursor.moveToFirst();
@@ -65,6 +69,8 @@ public class CacheController{
do{
Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class);
status.postprocess();
int flags=cursor.getInt(1);
status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0);
newMaxID=status.id;
for(Filter filter:filters){
if(filter.matches(status.getContentStatus().content))
@@ -73,25 +79,18 @@ public class CacheController{
result.add(status);
}while(cursor.moveToNext());
String _newMaxID=newMaxID;
uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID)));
uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true)));
return;
}
}catch(IOException x){
Log.w(TAG, "getHomeTimeline: corrupted status object in database", x);
}
}
new GetHomeTimeline(maxID, null, count)
new GetHomeTimeline(maxID, null, count, null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
callback.onSuccess(new PaginatedResponse<>(result.stream().filter(post->{
for(Filter filter:filters){
if(filter.matches(post.getContentStatus().content)){
return false;
}
}
return true;
}).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id));
callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false));
putHomeTimeline(result, maxID==null);
}
@@ -110,14 +109,18 @@ public class CacheController{
}, 0);
}
private void putHomeTimeline(List<Status> posts, boolean clear){
public void putHomeTimeline(List<Status> posts, boolean clear){
runOnDbThread((db)->{
if(clear)
db.delete("home_timeline", null, null);
ContentValues values=new ContentValues(2);
ContentValues values=new ContentValues(3);
for(Status s:posts){
values.put("id", s.id);
values.put("json", MastodonAPIController.gson.toJson(s));
int flags=0;
if(s.hasGapAfter)
flags|=POST_FLAG_GAP_AFTER;
values.put("flags", flags);
db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
});

View File

@@ -1,14 +1,10 @@
package org.joinmastodon.android.api;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.util.Log;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonIOException;
import com.google.gson.JsonObject;
@@ -16,11 +12,9 @@ import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter;
import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.model.BaseModel;
import java.io.IOException;
import java.io.Reader;
@@ -144,7 +138,7 @@ public class MastodonAPIController{
}
try{
req.validateAndPostprocessResponse(respObj);
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);

View File

@@ -28,6 +28,7 @@ import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import okhttp3.Call;
import okhttp3.RequestBody;
import okhttp3.Response;
public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
private static final String TAG="MastodonAPIRequest";
@@ -75,9 +76,14 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
}
public MastodonAPIRequest<T> exec(String accountID){
account=AccountSessionManager.getInstance().getAccount(accountID);
domain=account.domain;
account.getApiController().submitRequest(this);
try{
account=AccountSessionManager.getInstance().getAccount(accountID);
domain=account.domain;
account.getApiController().submitRequest(this);
}catch(Exception x){
Log.e(TAG, "exec: this shouldn't happen, but it still did", x);
invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1));
}
return this;
}
@@ -153,7 +159,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
}
@CallSuper
public void validateAndPostprocessResponse(T respObj) throws IOException{
public void validateAndPostprocessResponse(T respObj, Response httpResponse) throws IOException{
if(respObj instanceof BaseModel){
((BaseModel) respObj).postprocess();
}else if(respObj instanceof List){

View File

@@ -26,6 +26,8 @@ public class MastodonErrorResponse extends ErrorResponse{
@Override
public void showToast(Context context){
if(context==null)
return;
Toast.makeText(context, error, Toast.LENGTH_SHORT).show();
}
}

View File

@@ -121,16 +121,12 @@ public class PushSubscriptionManager{
return !TextUtils.isEmpty(deviceToken);
}
public void registerAccountForPush(){
registerAccountForPush(null);
}
public void registerAccountForPush(PushSubscription subscription){
if(TextUtils.isEmpty(deviceToken))
throw new IllegalStateException("No device push token available");
MastodonAPIController.runInBackground(()->{
Log.d(TAG, "registerAccountForPush: started for "+accountID);
String encodedPublicKey, encodedAuthKey;
String encodedPublicKey, encodedAuthKey, pushAccountID;
try{
KeyPairGenerator generator=KeyPairGenerator.getInstance("EC");
ECGenParameterSpec spec=new ECGenParameterSpec(EC_CURVE_NAME);
@@ -140,11 +136,17 @@ public class PushSubscriptionManager{
privateKey=keyPair.getPrivate();
encodedPublicKey=Base64.encodeToString(serializeRawPublicKey(publicKey), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
authKey=new byte[16];
new SecureRandom().nextBytes(authKey);
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
SecureRandom secureRandom=new SecureRandom();
secureRandom.nextBytes(authKey);
byte[] randomAccountID=new byte[16];
secureRandom.nextBytes(randomAccountID);
AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID);
if(session==null)
return;
session.pushPrivateKey=Base64.encodeToString(privateKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
session.pushPublicKey=Base64.encodeToString(publicKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
session.pushAuthKey=encodedAuthKey=Base64.encodeToString(authKey, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
session.pushAccountID=pushAccountID=Base64.encodeToString(randomAccountID, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
AccountSessionManager.getInstance().writeAccountsFile();
}catch(NoSuchAlgorithmException|InvalidAlgorithmParameterException e){
Log.e(TAG, "registerAccountForPush: error generating encryption key", e);
@@ -155,14 +157,16 @@ public class PushSubscriptionManager{
encodedAuthKey,
subscription==null ? PushSubscription.Alerts.ofAll() : subscription.alerts,
subscription==null ? PushSubscription.Policy.ALL : subscription.policy,
accountID)
pushAccountID)
.setCallback(new Callback<>(){
@Override
public void onSuccess(PushSubscription result){
MastodonAPIController.runInBackground(()->{
serverKey=deserializeRawPublicKey(Base64.decode(result.serverKey, Base64.URL_SAFE));
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID);
if(session==null)
return;
session.pushSubscription=result;
AccountSessionManager.getInstance().writeAccountsFile();
Log.d(TAG, "Successfully registered "+accountID+" for push notifications");
@@ -183,7 +187,9 @@ public class PushSubscriptionManager{
.setCallback(new Callback<>(){
@Override
public void onSuccess(PushSubscription result){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID);
if(session==null)
return;
if(result.policy!=subscription.policy)
result.policy=subscription.policy;
session.pushSubscription=result;
@@ -196,7 +202,9 @@ public class PushSubscriptionManager{
if(((MastodonErrorResponse)error).httpStatus==404){ // Not registered for push, register now
registerAccountForPush(subscription);
}else{
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID);
if(session==null)
return;
session.needUpdatePushSettings=true;
session.pushSubscription=subscription;
AccountSessionManager.getInstance().writeAccountsFile();
@@ -359,7 +367,7 @@ public class PushSubscriptionManager{
private static void registerAllAccountsForPush(boolean forceReRegister){
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
if(session.pushSubscription==null || forceReRegister)
session.getPushSubscriptionManager().registerAccountForPush();
session.getPushSubscriptionManager().registerAccountForPush(session.pushSubscription);
else if(session.needUpdatePushSettings)
session.getPushSubscriptionManager().updatePushSettings(session.pushSubscription);
}

View File

@@ -60,7 +60,7 @@ public class ResizedImageRequestBody extends CountingRequestBody{
}
if(factor%1f!=0f){
Bitmap scaled=Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888);
new Canvas(scaled).drawBitmap(bitmap, null, new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()), new Paint(Paint.FILTER_BITMAP_FLAG));
new Canvas(scaled).drawBitmap(bitmap, null, new Rect(0, 0, targetWidth, targetHeight), new Paint(Paint.FILTER_BITMAP_FLAG));
bitmap=scaled;
}
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){

View File

@@ -58,6 +58,7 @@ public class StatusInteractionController{
status.favouritesCount++;
else
status.favouritesCount--;
E.post(new StatusCountersUpdatedEvent(status));
}
public void setReblogged(Status status, boolean reblogged){
@@ -95,5 +96,6 @@ public class StatusInteractionController{
status.reblogsCount++;
else
status.reblogsCount--;
E.post(new StatusCountersUpdatedEvent(status));
}
}

View File

@@ -0,0 +1,57 @@
package org.joinmastodon.android.api.requests;
import android.net.Uri;
import android.text.TextUtils;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.HeaderPaginationList;
import java.io.IOException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import okhttp3.Response;
public abstract class HeaderPaginationRequest<I> extends MastodonAPIRequest<HeaderPaginationList<I>>{
private static final Pattern LINK_HEADER_PATTERN=Pattern.compile("(?:(?:,\\s*)?<([^>]+)>|;\\s*(\\w+)=['\"](\\w+)['\"])");
public HeaderPaginationRequest(HttpMethod method, String path, Class<HeaderPaginationList<I>> respClass){
super(method, path, respClass);
}
public HeaderPaginationRequest(HttpMethod method, String path, TypeToken<HeaderPaginationList<I>> respTypeToken){
super(method, path, respTypeToken);
}
@Override
public void validateAndPostprocessResponse(HeaderPaginationList<I> respObj, Response httpResponse) throws IOException{
super.validateAndPostprocessResponse(respObj, httpResponse);
String link=httpResponse.header("Link");
if(!TextUtils.isEmpty(link)){
Matcher matcher=LINK_HEADER_PATTERN.matcher(link);
String url=null;
while(matcher.find()){
if(url==null){
String _url=matcher.group(1);
if(_url==null)
continue;
url=_url;
}else{
String paramName=matcher.group(2);
String paramValue=matcher.group(3);
if(paramName==null || paramValue==null)
return;
if("rel".equals(paramName)){
switch(paramValue){
case "next" -> respObj.nextPageUri=Uri.parse(url);
case "prev" -> respObj.prevPageUri=Uri.parse(url);
}
url=null;
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,12 +8,14 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetHomeTimeline extends MastodonAPIRequest<List<Status>>{
public GetHomeTimeline(String maxID, String minID, int limit){
public GetHomeTimeline(String maxID, String minID, int limit, String sinceID){
super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(sinceID!=null)
addQueryParameter("since_id", sinceID);
if(limit>0)
addQueryParameter("limit", ""+limit);
}

View File

@@ -0,0 +1,24 @@
package org.joinmastodon.android.api.requests.timelines;
import android.text.TextUtils;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{
public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit){
super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){});
if(local)
addQueryParameter("local", "true");
if(remote)
addQueryParameter("remote", "true");
if(!TextUtils.isEmpty(maxID))
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View File

@@ -27,6 +27,7 @@ public class AccountSession{
public boolean needUpdatePushSettings;
public long filtersLastUpdated;
public List<Filter> wordFilters=new ArrayList<>();
public String pushAccountID;
private transient MastodonAPIController apiController;
private transient StatusInteractionController statusInteractionController;
private transient CacheController cacheController;

View File

@@ -100,7 +100,7 @@ public class AccountSessionManager{
writeAccountsFile();
updateInstanceEmojis(instance, instance.uri);
if(PushSubscriptionManager.arePushNotificationsAvailable()){
session.getPushSubscriptionManager().registerAccountForPush();
session.getPushSubscriptionManager().registerAccountForPush(null);
}
}
@@ -133,10 +133,20 @@ public class AccountSessionManager{
return session;
}
@Nullable
public AccountSession tryGetAccount(String id){
return sessions.get(id);
}
@Nullable
public AccountSession getLastActiveAccount(){
if(sessions.isEmpty() || lastActiveAccountID==null)
return null;
if(!sessions.containsKey(lastActiveAccountID)){
// TODO figure out why this happens. It should not be possible.
lastActiveAccountID=getLoggedInAccounts().get(0).getID();
writeAccountsFile();
}
return getAccount(lastActiveAccountID);
}
@@ -197,6 +207,7 @@ public class AccountSessionManager{
new CustomTabsIntent.Builder()
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.setShowTitle(true)
.build()
.launchUrl(activity, uri);
}

View File

@@ -47,6 +47,8 @@ public class AccountTimelineFragment extends StatusListFragment{
@Override
protected void doLoadData(int offset, int count){
if(user==null) // TODO figure out why this happens
return;
currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter)
.setCallback(new SimpleCallback<>(this){
@Override

View File

@@ -31,6 +31,8 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.TileGridLayoutManager;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem;
@@ -243,6 +245,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
private ImageStatusDisplayItem.Holder<?> findPhotoViewHolder(int index){
if(list==null)
return null;
int offset=0;
for(StatusDisplayItem item:displayItems){
if(item.parentID.equals(parentID)){
@@ -279,6 +283,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
list.getDecoratedBoundsWithMargins(view, outRect);
RecyclerView.ViewHolder holder=list.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder){
if(((StatusDisplayItem.Holder<?>) holder).getItem().getType()==StatusDisplayItem.Type.GAP){
outRect.setEmpty();
return;
}
String id=((StatusDisplayItem.Holder<?>) holder).getItemID();
for(int i=0;i<list.getChildCount();i++){
View child=list.getChildAt(i);
@@ -356,6 +364,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
public abstract void onItemClick(String id);
protected void updatePoll(String itemID, Status status, Poll poll){
status.poll=poll;
int firstOptionIndex=-1, footerIndex=-1;
int i=0;
for(StatusDisplayItem item:displayItems){
@@ -472,6 +481,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
}
public void onGapClick(GapStatusDisplayItem.Holder item){}
public String getAccountID(){
return accountID;
}
@@ -651,8 +662,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
View bottomSibling=parent.getChildAt(i+1);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
RecyclerView.ViewHolder siblingHolder=parent.getChildViewHolder(bottomSibling);
if(holder instanceof StatusDisplayItem.Holder && siblingHolder instanceof StatusDisplayItem.Holder
&& !((StatusDisplayItem.Holder<?>) holder).getItemID().equals(((StatusDisplayItem.Holder<?>) siblingHolder).getItemID())){
if(holder instanceof StatusDisplayItem.Holder<?> ih && siblingHolder instanceof StatusDisplayItem.Holder<?> sh
&& (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP){
drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, dividerPaint);
}
}

View File

@@ -382,6 +382,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
if(s.length()==0)
return;
// offset one char back to catch an already typed '@' or '#' or ':'
int realStart=start;
start=Math.max(0, start-1);
@@ -447,7 +449,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if(!mentions.contains(m))
mentions.add(m);
}
initialText=TextUtils.join(" ", mentions)+" ";
initialText=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" ";
if(savedInstanceState==null){
mainEditText.setText(initialText);
mainEditText.setSelection(mainEditText.length());
@@ -607,6 +609,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void onSuccess(Status result){
wm.removeView(sendingOverlay);
sendingOverlay=null;
Nav.finish(ComposeFragment.this);
E.post(new StatusCreatedEvent(result));
if(replyTo!=null){
@@ -618,6 +621,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void onError(ErrorResponse error){
wm.removeView(sendingOverlay);
sendingOverlay=null;
sendProgress.setVisibility(View.GONE);
sendError.setVisibility(View.VISIBLE);
publishButton.setEnabled(true);
@@ -645,6 +649,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
confirmDiscardDraftAndFinish();
return true;
}
if(sendingOverlay!=null)
return true;
return false;
}
@@ -714,25 +720,27 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
return false;
}
String type=getActivity().getContentResolver().getType(uri);
if(instance.configuration!=null && instance.configuration.mediaAttachments!=null){
if(instance!=null && instance.configuration!=null && instance.configuration.mediaAttachments!=null){
if(instance.configuration.mediaAttachments.supportedMimeTypes!=null && !instance.configuration.mediaAttachments.supportedMimeTypes.contains(type)){
showMediaAttachmentError(getString(R.string.media_attachment_unsupported_type, UiUtils.getFileName(uri)));
return false;
}
int sizeLimit=type.startsWith("image/") ? instance.configuration.mediaAttachments.imageSizeLimit : instance.configuration.mediaAttachments.videoSizeLimit;
int size;
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
cursor.moveToFirst();
size=cursor.getInt(0);
}catch(Exception x){
Log.w("ComposeFragment", x);
return false;
}
if(size>sizeLimit){
float mb=sizeLimit/(float)(1024*1024);
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb);
showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
return false;
if(!type.startsWith("image/")){
int sizeLimit=instance.configuration.mediaAttachments.videoSizeLimit;
int size;
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
cursor.moveToFirst();
size=cursor.getInt(0);
}catch(Exception x){
Log.w("ComposeFragment", x);
return false;
}
if(size>sizeLimit){
float mb=sizeLimit/(float) (1024*1024);
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb);
showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
return false;
}
}
}
pollBtn.setEnabled(false);
@@ -1049,6 +1057,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}
private void updateVisibilityIcon(){
if(statusVisibility==null){ // TODO find out why this happens
statusVisibility=StatusPrivacy.PUBLIC;
}
visibilityBtn.setImageResource(switch(statusVisibility){
case PUBLIC -> R.drawable.ic_fluent_earth_24_filled;
case UNLISTED -> R.drawable.ic_fluent_people_community_24_regular;
@@ -1059,7 +1070,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void onSelectionChanged(int start, int end){
if(start==end){
if(start==end && mainEditText.length()>0){
ComposeAutocompleteSpan[] spans=mainEditText.getText().getSpans(start, end, ComposeAutocompleteSpan.class);
if(spans.length>0){
assert spans.length==1;
@@ -1093,7 +1104,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public String[] onGetAllowedMediaMimeTypes(){
if(instance.configuration!=null && instance.configuration.mediaAttachments!=null && instance.configuration.mediaAttachments.supportedMimeTypes!=null)
if(instance!=null && instance.configuration!=null && instance.configuration.mediaAttachments!=null && instance.configuration.mediaAttachments.supportedMimeTypes!=null)
return instance.configuration.mediaAttachments.supportedMimeTypes.toArray(new String[0]);
return new String[]{"image/jpeg", "image/gif", "image/png", "video/mp4"};
}

View File

@@ -5,6 +5,7 @@ import android.app.NotificationManager;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Outline;
import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
@@ -17,6 +18,7 @@ import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
@@ -28,6 +30,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.discover.DiscoverFragment;
import org.joinmastodon.android.fragments.discover.SearchFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.TabBar;
@@ -46,6 +49,7 @@ import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.BottomSheet;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class HomeFragment extends AppKitFragment implements OnBackPressedListener{
@@ -238,21 +242,11 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
private boolean onTabLongClick(@IdRes int tab){
if(tab==R.id.tab_profile){
ArrayList<String> options=new ArrayList<>();
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")");
}
new M3AlertDialogBuilder(getActivity())
.setItems(options.toArray(new String[0]), (dialog, which)->{
AccountSession session=AccountSessionManager.getInstance().getLoggedInAccounts().get(which);
AccountSessionManager.getInstance().setLastActiveAccountID(session.getID());
getActivity().finish();
getActivity().startActivity(new Intent(getActivity(), MainActivity.class));
})
.setNegativeButton(R.string.add_account, (dialog, which)->{
Nav.go(getActivity(), SplashFragment.class, null);
})
.show();
ArrayList<String> options=new ArrayList<>();
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")");
}
new AccountSwitcherSheet(getActivity()).show();
return true;
}
return false;

View File

@@ -1,14 +1,22 @@
package org.joinmastodon.android.fragments;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
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.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.Toolbar;
@@ -16,20 +24,38 @@ import android.widget.Toolbar;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.Status;
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.utils.StatusFilterPredicate;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class HomeTimelineFragment extends StatusListFragment{
private ImageButton fab;
private ImageView toolbarLogo;
private Button toolbarShowNewPostsBtn;
private boolean newPostsBtnShown;
private AnimatorSet currentNewPostsAnim;
private String maxID;
@@ -50,11 +76,13 @@ public class HomeTimelineFragment extends StatusListFragment{
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(PaginatedResponse<List<Status>> result){
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null)
return;
onDataLoaded(result.items, !result.items.isEmpty());
maxID=result.maxID;
if(result.isFromCache())
loadNewPosts();
}
});
}
@@ -65,6 +93,14 @@ public class HomeTimelineFragment extends StatusListFragment{
fab=view.findViewById(R.id.fab);
fab.setOnClickListener(this::onFabClick);
updateToolbarLogo();
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
if(newPostsBtnShown && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){
hideNewPostsButton();
}
}
});
}
@Override
@@ -89,8 +125,13 @@ public class HomeTimelineFragment extends StatusListFragment{
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
if(!getArguments().getBoolean("noAutoLoad")){
if(!loaded && !dataLoading){
loadData();
}else if(!dataLoading){
loadNewPosts();
}
}
}
@Subscribe
@@ -104,12 +145,256 @@ public class HomeTimelineFragment extends StatusListFragment{
Nav.go(getActivity(), ComposeFragment.class, args);
}
private void loadNewPosts(){
dataLoading=true;
// The idea here is that we request the timeline such that if there are fewer than `limit` posts,
// we'll get the currently topmost post as last in the response. This way we know there's no gap
// between the existing and newly loaded parts of the timeline.
String sinceID=data.size()>1 ? data.get(1).id : "1";
currentRequest=new GetHomeTimeline(null, null, 20, sinceID)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
if(result.isEmpty() || getActivity()==null)
return;
Status last=result.get(result.size()-1);
List<Status> toAdd;
if(!data.isEmpty() && last.id.equals(data.get(0).id)){ // This part intersects with the existing one
toAdd=result.subList(0, result.size()-1); // Remove the already known last post
}else{
result.get(result.size()-1).hasGapAfter=true;
toAdd=result;
}
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
if(!filters.isEmpty()){
toAdd=toAdd.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList());
}
if(!toAdd.isEmpty()){
prependItems(toAdd, true);
showNewPostsButton();
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false);
}
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
dataLoading=false;
}
})
.exec(accountID);
}
@Override
public void onGapClick(GapStatusDisplayItem.Holder item){
if(dataLoading)
return;
item.getItem().loading=true;
V.setVisibilityAnimated(item.progress, View.VISIBLE);
V.setVisibilityAnimated(item.text, View.GONE);
GapStatusDisplayItem gap=item.getItem();
dataLoading=true;
currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
if(getActivity()==null)
return;
int gapPos=displayItems.indexOf(gap);
if(gapPos==-1)
return;
if(result.isEmpty()){
displayItems.remove(gapPos);
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
Status gapStatus=getStatusByID(gap.parentID);
if(gapStatus!=null){
gapStatus.hasGapAfter=false;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
}
}else{
Set<String> idsBelowGap=new HashSet<>();
boolean belowGap=false;
int gapPostIndex=0;
for(Status s:data){
if(belowGap){
idsBelowGap.add(s.id);
}else if(s.id.equals(gap.parentID)){
belowGap=true;
s.hasGapAfter=false;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false);
}else{
gapPostIndex++;
}
}
int endIndex=0;
for(Status s:result){
endIndex++;
if(idsBelowGap.contains(s.id))
break;
}
if(endIndex==result.size()){
result.get(result.size()-1).hasGapAfter=true;
}else{
result=result.subList(0, endIndex);
}
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
outer:
for(Status s:result){
if(idsBelowGap.contains(s.id))
break;
for(Filter filter:filters){
if(filter.matches(s.getContentStatus().content)){
continue outer;
}
}
targetList.addAll(buildDisplayItems(s));
insertedPosts.add(s);
}
if(targetList.isEmpty()){
// oops. We didn't add new posts, but at least we know there are none.
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
}else{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
}
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
dataLoading=false;
gap.loading=false;
Activity a=getActivity();
if(a!=null){
error.showToast(a);
int gapPos=displayItems.indexOf(gap);
if(gapPos>=0)
adapter.notifyItemChanged(gapPos);
}
}
})
.exec(accountID);
}
@Override
public void onRefresh(){
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
dataLoading=false;
}
super.onRefresh();
}
private void updateToolbarLogo(){
ImageView logo=new ImageView(getActivity());
logo.setScaleType(ImageView.ScaleType.CENTER);
logo.setImageResource(R.drawable.logo);
logo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)));
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);
}
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));
Toolbar toolbar=getToolbar();
toolbar.addView(logo, new Toolbar.LayoutParams(Gravity.CENTER));
toolbar.addView(logoWrap, new Toolbar.LayoutParams(Gravity.CENTER));
}
private void showNewPostsButton(){
if(newPostsBtnShown)
return;
newPostsBtnShown=true;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
toolbarShowNewPostsBtn.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)
);
set.setDuration(300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
toolbarLogo.setVisibility(View.INVISIBLE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
private void hideNewPostsButton(){
if(!newPostsBtnShown)
return;
newPostsBtnShown=false;
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)
);
set.setDuration(300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
private void onNewPostsBtnClick(View v){
if(newPostsBtnShown){
hideNewPostsButton();
scrollToTop();
}
}
}

View File

@@ -179,7 +179,6 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
rect.set(child.getX(), i==0 && pos>0 && displayItems.get(pos-1).inset ? V.dp(-10) : child.getY(), child.getX()+child.getWidth(), child.getY()+child.getHeight());
}else{
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight());
rect.right=Math.max(rect.right, child.getX()+child.getHeight());
}
}else if(!rect.isEmpty()){
drawInsetBackground(c);
@@ -198,6 +197,8 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
private void drawInsetBackground(Canvas c){
paint.setStyle(Paint.Style.FILL);
paint.setColor(bgColor);
rect.left=V.dp(12);
rect.right=list.getWidth()-V.dp(12);
rect.inset(V.dp(4), V.dp(4));
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
paint.setStyle(Paint.Style.STROKE);

View File

@@ -9,11 +9,14 @@ import android.app.Fragment;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Outline;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.ImageSpan;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.Menu;
@@ -42,12 +45,17 @@ import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.account_list.FollowerListFragment;
import org.joinmastodon.android.fragments.account_list.FollowingListFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AccountField;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.ui.SimpleViewHolder;
import org.joinmastodon.android.ui.SingleImagePhotoViewerListener;
import org.joinmastodon.android.ui.drawables.CoverOverlayGradientDrawable;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import org.joinmastodon.android.ui.tabs.TabLayout;
import org.joinmastodon.android.ui.tabs.TabLayoutMediator;
import org.joinmastodon.android.ui.text.CustomEmojiSpan;
@@ -104,6 +112,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
private ProgressBar actionProgress;
private FrameLayout[] tabViews;
private TabLayoutMediator tabLayoutMediator;
private TextView followsYouView;
private Account account;
private String accountID;
@@ -118,6 +127,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
private boolean refreshing;
private View fab;
private WindowInsets childInsets;
private PhotoViewer currentPhotoViewer;
private boolean editModeLoading;
public ProfileFragment(){
super(R.layout.loader_fragment_overlay_toolbar);
@@ -178,6 +189,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
bioEdit=content.findViewById(R.id.bio_edit);
actionProgress=content.findViewById(R.id.action_progress);
fab=content.findViewById(R.id.fab);
followsYouView=content.findViewById(R.id.follows_you);
avatar.setOutlineProvider(new ViewOutlineProvider(){
@Override
@@ -257,6 +269,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
fab.setVisibility(View.GONE);
}
followersBtn.setOnClickListener(this::onFollowersOrFollowingClick);
followingBtn.setOnClickListener(this::onFollowersOrFollowingClick);
return sizeWrapper;
}
@@ -400,8 +415,25 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
HtmlParser.parseCustomEmoji(ssb, account.emojis);
name.setText(ssb);
setTitle(ssb);
username.setText('@'+account.acct);
bio.setText(HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID));
if(account.locked){
ssb=new SpannableStringBuilder("@");
ssb.append(account.acct);
ssb.append(" ");
Drawable lock=username.getResources().getDrawable(R.drawable.ic_fluent_lock_closed_20_filled, getActivity().getTheme()).mutate();
lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight());
lock.setTint(username.getCurrentTextColor());
ssb.append(getString(R.string.manually_approves_followers), new ImageSpan(lock, ImageSpan.ALIGN_BOTTOM), 0);
username.setText(ssb);
}else{
username.setText('@'+account.acct);
}
CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
if(TextUtils.isEmpty(parsedBio)){
bio.setVisibility(View.GONE);
}else{
bio.setVisibility(View.VISIBLE);
bio.setText(parsedBio);
}
followersCount.setText(UiUtils.abbreviateNumber(account.followersCount));
followingCount.setText(UiUtils.abbreviateNumber(account.followingCount));
postsCount.setText(UiUtils.abbreviateNumber(account.statusesCount));
@@ -565,16 +597,16 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
invalidateOptionsMenu();
actionButton.setVisibility(View.VISIBLE);
UiUtils.setRelationshipToActionButton(relationship, actionButton);
actionProgress.setIndeterminateTintList(actionButton.getTextColors());
followsYouView.setVisibility(relationship.followedBy ? View.VISIBLE : View.GONE);
}
private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){
int topBarsH=getToolbar().getHeight()+statusBarHeight;
if(scrollY>avatar.getTop()-topBarsH){
float avaAlpha=Math.max(1f-((scrollY-(avatar.getTop()-topBarsH))/(float)V.dp(38)), 0f);
avatar.setAlpha(avaAlpha);
if(scrollY>avatarBorder.getTop()-topBarsH){
float avaAlpha=Math.max(1f-((scrollY-(avatarBorder.getTop()-topBarsH))/(float)V.dp(38)), 0f);
avatarBorder.setAlpha(avaAlpha);
}else{
avatar.setAlpha(1f);
avatarBorder.setAlpha(1f);
}
if(scrollY>cover.getHeight()-topBarsH){
@@ -596,6 +628,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
toolbarTitleView.setTranslationY(titleTransY);
toolbarSubtitleView.setTranslationY(titleTransY);
}
if(currentPhotoViewer!=null){
currentPhotoViewer.offsetView(0, oldScrollY-scrollY);
}
}
private Fragment getFragmentForPage(int page){
@@ -630,17 +665,26 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
}
private void loadAccountInfoAndEnterEditMode(){
if(editModeLoading)
return;
editModeLoading=true;
setActionProgressVisible(true);
new GetOwnAccount()
.setCallback(new Callback<>(){
@Override
public void onSuccess(Account result){
editModeLoading=false;
if(getActivity()==null)
return;
enterEditMode(result);
setActionProgressVisible(false);
}
@Override
public void onError(ErrorResponse error){
editModeLoading=false;
if(getActivity()==null)
return;
error.showToast(getActivity());
setActionProgressVisible(false);
}
@@ -778,15 +822,38 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
return false;
}
private List<Attachment> createFakeAttachments(String url, Drawable drawable){
Attachment att=new Attachment();
att.type=Attachment.Type.IMAGE;
att.url=url;
att.meta=new Attachment.Metadata();
att.meta.width=drawable.getIntrinsicWidth();
att.meta.height=drawable.getIntrinsicHeight();
return Collections.singletonList(att);
}
private void onAvatarClick(View v){
if(isInEditMode){
startImagePicker(AVATAR_RESULT);
}else{
Drawable ava=avatar.getDrawable();
if(ava==null)
return;
int radius=V.dp(25);
currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.avatar, ava), 0,
new SingleImagePhotoViewerListener(avatar, avatarBorder, new int[]{radius, radius, radius, radius}, this, ()->currentPhotoViewer=null, ()->ava, null, null));
}
}
private void onCoverClick(View v){
if(isInEditMode){
startImagePicker(COVER_RESULT);
}else{
Drawable drawable=cover.getDrawable();
if(drawable==null || drawable instanceof ColorDrawable)
return;
currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.header, drawable), 0,
new SingleImagePhotoViewerListener(cover, cover, null, this, ()->currentPhotoViewer=null, ()->drawable, ()->avatarBorder.setTranslationZ(2), ()->avatarBorder.setTranslationZ(0)));
}
}
@@ -825,6 +892,20 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
scrollView.smoothScrollTo(0, 0);
}
private void onFollowersOrFollowingClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("targetAccount", Parcels.wrap(account));
Class<? extends Fragment> cls;
if(v.getId()==R.id.followers_btn)
cls=FollowerListFragment.class;
else if(v.getId()==R.id.following_btn)
cls=FollowingListFragment.class;
else
return;
Nav.go(getActivity(), cls, args);
}
private class ProfilePagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{
@NonNull
@Override

View File

@@ -13,6 +13,8 @@ public interface ScrollableToTop{
* @param list
*/
default void smoothScrollRecyclerViewToTop(RecyclerView list){
if(list==null) // TODO find out why this happens because it should not be possible
return;
if(list.getChildCount()>0 && list.getChildAdapterPosition(list.getChildAt(0))>10){
list.scrollToPosition(0);
list.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){

View File

@@ -10,6 +10,7 @@ import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusDeletedEvent;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
@@ -90,16 +91,15 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof FooterStatusDisplayItem.Holder footer && footer.getItem().status==s.getContentStatus()){
footer.rebind();
return;
}else if(holder instanceof ExtendedFooterStatusDisplayItem.Holder footer && footer.getItem().status==s.getContentStatus()){
footer.rebind();
}
}
return;
}
}
for(Status s:preloadedData){
if(s.id.equals(ev.id)){
s.update(ev);
return;
}
}
}

View File

@@ -11,6 +11,8 @@ import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusContext;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem;
import org.joinmastodon.android.ui.text.HtmlParser;
@@ -45,7 +47,10 @@ public class ThreadFragment extends StatusListFragment{
for(StatusDisplayItem item:items){
if(item instanceof TextStatusDisplayItem text)
text.textSelectable=true;
else if(item instanceof FooterStatusDisplayItem footer)
footer.hideCounts=true;
}
items.add(new ExtendedFooterStatusDisplayItem(s.id, this, s.getContentStatus()));
}
return items;
}
@@ -56,6 +61,8 @@ public class ThreadFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(StatusContext result){
if(getActivity()==null)
return;
if(refreshing){
data.clear();
displayItems.clear();
@@ -64,7 +71,8 @@ public class ThreadFragment extends StatusListFragment{
}
result.descendants=filterStatuses(result.descendants);
result.ancestors=filterStatuses(result.ancestors);
footerProgress.setVisibility(View.GONE);
if(footerProgress!=null)
footerProgress.setVisibility(View.GONE);
data.addAll(result.descendants);
int prevCount=displayItems.size();
onAppendItems(result.descendants);

View File

@@ -0,0 +1,17 @@
package org.joinmastodon.android.fragments.account_list;
import android.os.Bundle;
import org.joinmastodon.android.model.Account;
import org.parceler.Parcels;
public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment{
protected Account account;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
setTitle("@"+account.acct);
}
}

View File

@@ -0,0 +1,394 @@
package org.joinmastodon.android.fragments.account_list;
import android.app.ProgressDialog;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toolbar;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
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.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class BaseAccountListFragment extends BaseRecyclerFragment<BaseAccountListFragment.AccountItem>{
protected HashMap<String, Relationship> relationships=new HashMap<>();
protected String accountID;
protected ArrayList<APIRequest<?>> relationshipsRequests=new ArrayList<>();
public BaseAccountListFragment(){
super(40);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
}
@Override
protected void onDataLoaded(List<AccountItem> d, boolean more){
if(refreshing){
relationships.clear();
}
loadRelationships(d);
super.onDataLoaded(d, more);
}
@Override
public void onRefresh(){
for(APIRequest<?> req:relationshipsRequests){
req.cancel();
}
relationshipsRequests.clear();
super.onRefresh();
}
protected void loadRelationships(List<AccountItem> accounts){
Set<String> ids=accounts.stream().map(ai->ai.account.id).collect(Collectors.toSet());
GetAccountRelationships req=new GetAccountRelationships(ids);
relationshipsRequests.add(req);
req.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Relationship> result){
relationshipsRequests.remove(req);
for(Relationship rel:result){
relationships.put(rel.id, rel);
}
if(list==null)
return;
for(int i=0;i<list.getChildCount();i++){
if(list.getChildViewHolder(list.getChildAt(i)) instanceof AccountViewHolder avh){
avh.bindRelationship();
}
}
}
@Override
public void onError(ErrorResponse error){
relationshipsRequests.remove(req);
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter getAdapter(){
return new AccountsAdapter();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
// list.setPadding(0, V.dp(16), 0, V.dp(16));
list.setClipToPadding(false);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 72, 16));
updateToolbar();
}
@Override
public void onConfigurationChanged(Configuration newConfig){
super.onConfigurationChanged(newConfig);
updateToolbar();
}
@CallSuper
protected void updateToolbar(){
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){
list.setPadding(0, V.dp(16), 0, V.dp(16)+insets.getSystemWindowInsetBottom());
insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom());
}else{
list.setPadding(0, V.dp(16), 0, V.dp(16));
}
super.onApplyWindowInsets(insets);
}
protected class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
super(imgLoader);
}
@NonNull
@Override
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new AccountViewHolder();
}
@Override
public void onBindViewHolder(AccountViewHolder holder, int position){
holder.bind(data.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return data.size();
}
@Override
public int getImageCountForItem(int position){
return data.get(position).emojiHelper.getImageCount()+1;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
AccountItem item=data.get(position);
return image==0 ? item.avaRequest : item.emojiHelper.getImageRequest(image-1);
}
}
protected class AccountViewHolder extends BindableViewHolder<AccountItem> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable, UsableRecyclerView.LongClickable{
private final TextView name, username;
private final ImageView avatar;
private final Button button;
private final PopupMenu contextMenu;
private final View menuAnchor;
public AccountViewHolder(){
super(getActivity(), R.layout.item_account_list, list);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
avatar=findViewById(R.id.avatar);
button=findViewById(R.id.button);
menuAnchor=findViewById(R.id.menu_anchor);
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
avatar.setClipToOutline(true);
button.setOnClickListener(this::onButtonClick);
contextMenu=new PopupMenu(getActivity(), menuAnchor);
contextMenu.inflate(R.menu.profile);
contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected);
}
@Override
public void onBind(AccountItem item){
name.setText(item.parsedName);
username.setText("@"+item.account.acct);
bindRelationship();
}
public void bindRelationship(){
Relationship rel=relationships.get(item.account.id);
if(rel==null || AccountSessionManager.getInstance().isSelf(accountID, item.account)){
button.setVisibility(View.GONE);
}else{
button.setVisibility(View.VISIBLE);
UiUtils.setRelationshipToActionButton(rel, button);
}
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
avatar.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
name.invalidate();
}
if(image instanceof Animatable a && !a.isRunning())
a.start();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
@Override
public void onClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
@Override
public boolean onLongClick(){
return false;
}
@Override
public boolean onLongClick(float x, float y){
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(getString(R.string.share_user, account.getDisplayUsername()));
menu.findItem(R.id.mute).setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername()));
menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.getDisplayUsername()));
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
if(relationship.following){
hideBoosts.setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
hideBoosts.setVisible(true);
}else{
hideBoosts.setVisible(false);
}
MenuItem blockDomain=menu.findItem(R.id.block_domain);
if(!account.isLocal()){
blockDomain.setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
blockDomain.setVisible(true);
}else{
blockDomain.setVisible(false);
}
menuAnchor.setTranslationX(x);
menuAnchor.setTranslationY(y);
contextMenu.show();
return true;
}
private void onButtonClick(View v){
ProgressDialog progress=new ProgressDialog(getActivity());
progress.setMessage(getString(R.string.loading));
progress.setCancelable(false);
UiUtils.performAccountAction(getActivity(), item.account, accountID, relationships.get(item.account.id), button, progressShown->{
itemView.setHasTransientState(progressShown);
if(progressShown)
progress.show();
else
progress.dismiss();
}, result->{
relationships.put(item.account.id, result);
bindRelationship();
});
}
private boolean onContextMenuItemSelected(MenuItem item){
Relationship relationship=relationships.get(this.item.account.id);
if(relationship==null)
return false;
Account account=this.item.account;
int id=item.getItemId();
if(id==R.id.share){
Intent intent=new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TEXT, account.url);
startActivity(Intent.createChooser(intent, item.getTitle()));
}else if(id==R.id.mute){
UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship);
}else if(id==R.id.block){
UiUtils.confirmToggleBlockUser(getActivity(), accountID, account, relationship.blocking, this::updateRelationship);
}else if(id==R.id.report){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("reportAccount", Parcels.wrap(account));
Nav.go(getActivity(), ReportReasonChoiceFragment.class, args);
}else if(id==R.id.open_in_browser){
UiUtils.launchWebBrowser(getActivity(), account.url);
}else if(id==R.id.block_domain){
UiUtils.confirmToggleBlockDomain(getActivity(), accountID, account.getDomain(), relationship.domainBlocking, ()->{
relationship.domainBlocking=!relationship.domainBlocking;
bindRelationship();
});
}else if(id==R.id.hide_boosts){
new SetAccountFollowed(account.id, true, !relationship.showingReblogs)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){
relationships.put(AccountViewHolder.this.item.account.id, result);
bindRelationship();
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, false)
.exec(accountID);
}
return true;
}
private void updateRelationship(Relationship r){
relationships.put(item.account.id, r);
bindRelationship();
}
}
protected static class AccountItem{
public final Account account;
public final ImageLoaderRequest avaRequest;
public final CustomEmojiHelper emojiHelper;
public final CharSequence parsedName;
public AccountItem(Account account){
this.account=account;
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(50), V.dp(50));
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis));
}
}
}

View File

@@ -0,0 +1,22 @@
package org.joinmastodon.android.fragments.account_list;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountFollowers;
import org.joinmastodon.android.model.Account;
public class FollowerListFragment extends AccountRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setSubtitle(getResources().getQuantityString(R.plurals.x_followers, account.followersCount, account.followersCount));
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetAccountFollowers(account.id, maxID, count);
}
}

View File

@@ -0,0 +1,22 @@
package org.joinmastodon.android.fragments.account_list;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountFollowing;
import org.joinmastodon.android.model.Account;
public class FollowingListFragment extends AccountRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setSubtitle(getResources().getQuantityString(R.plurals.x_following, account.followingCount, account.followingCount));
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetAccountFollowing(account.id, maxID, count);
}
}

View File

@@ -0,0 +1,38 @@
package org.joinmastodon.android.fragments.account_list;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.HeaderPaginationList;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{
private String nextMaxID;
public abstract HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count);
@Override
protected void doLoadData(int offset, int count){
currentRequest=onCreateRequest(offset==0 ? null : nextMaxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result.stream().map(AccountItem::new).collect(Collectors.toList()), nextMaxID!=null);
}
})
.exec(accountID);
}
@Override
public void onResume(){
super.onResume();
if(!loaded && !dataLoading)
loadData();
}
}

View File

@@ -0,0 +1,21 @@
package org.joinmastodon.android.fragments.account_list;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.statuses.GetStatusFavorites;
import org.joinmastodon.android.model.Account;
public class StatusFavoritesListFragment extends StatusRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(getResources().getQuantityString(R.plurals.x_favorites, status.favouritesCount, status.favouritesCount));
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetStatusFavorites(status.id, maxID, count);
}
}

View File

@@ -0,0 +1,21 @@
package org.joinmastodon.android.fragments.account_list;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.statuses.GetStatusReblogs;
import org.joinmastodon.android.model.Account;
public class StatusReblogsListFragment extends StatusRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(getResources().getQuantityString(R.plurals.x_reblogs, status.reblogsCount, status.reblogsCount));
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetStatusReblogs(status.id, maxID, count);
}
}

View File

@@ -0,0 +1,21 @@
package org.joinmastodon.android.fragments.account_list;
import android.os.Bundle;
import org.joinmastodon.android.model.Status;
import org.parceler.Parcels;
public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment{
protected Status status;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
status=Parcels.unwrap(getArguments().getParcelable("status"));
}
@Override
protected boolean hasSubtitle(){
return false;
}
}

View File

@@ -50,6 +50,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
private DiscoverNewsFragment newsFragment;
private DiscoverAccountsFragment accountsFragment;
private SearchFragment searchFragment;
private LocalTimelineFragment localTimelineFragment;
private String accountID;
private Runnable searchDebouncer=this::onSearchChangedDebounced;
@@ -71,14 +72,15 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
tabLayout=view.findViewById(R.id.tabbar);
pager=view.findViewById(R.id.pager);
tabViews=new FrameLayout[4];
tabViews=new FrameLayout[5];
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_users;
case 3 -> R.id.discover_local_timeline;
case 4 -> R.id.discover_users;
default -> throw new IllegalStateException("Unexpected value: "+i);
});
tabView.setVisibility(View.GONE);
@@ -121,8 +123,12 @@ 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)
@@ -136,7 +142,8 @@ 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.for_you;
case 3 -> R.string.local_timeline;
case 4 -> R.string.for_you;
default -> throw new IllegalStateException("Unexpected value: "+position);
});
tab.view.textView.setAllCaps(true);
@@ -250,7 +257,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
case 0 -> postsFragment;
case 1 -> hashtagsFragment;
case 2 -> newsFragment;
case 3 -> accountsFragment;
case 3 -> localTimelineFragment;
case 4 -> accountsFragment;
default -> throw new IllegalStateException("Unexpected value: "+page);
};
}
@@ -299,7 +307,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
@Override
public int getItemCount(){
return 4;
return tabViews.length;
}
@Override

View File

@@ -15,6 +15,7 @@ import org.joinmastodon.android.model.Card;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.Collections;
@@ -36,6 +37,7 @@ import me.grishka.appkit.views.UsableRecyclerView;
public class DiscoverNewsFragment extends BaseRecyclerFragment<Card> implements ScrollableToTop{
private String accountID;
private List<ImageLoaderRequest> imageRequests=Collections.emptyList();
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_LINKS);
public DiscoverNewsFragment(){
super(10);
@@ -71,6 +73,7 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<Card> implements
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 0, 0));
bannerHelper.maybeAddBanner(contentWrap);
}
@Override

View File

@@ -1,14 +1,20 @@
package org.joinmastodon.android.fragments.discover;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.api.requests.trends.GetTrendingStatuses;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import java.util.List;
import me.grishka.appkit.api.SimpleCallback;
public class DiscoverPostsFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_POSTS);
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetTrendingStatuses(count)
@@ -19,4 +25,10 @@ public class DiscoverPostsFragment extends StatusListFragment{
}
}).exec(accountID);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
bannerHelper.maybeAddBanner(contentWrap);
}
}

View File

@@ -0,0 +1,41 @@
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.fragments.StatusListFragment;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public class LocalTimelineFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE);
private String maxID;
@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;
onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty());
}
})
.exec(accountID);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
bannerHelper.maybeAddBanner(contentWrap);
}
}

View File

@@ -117,6 +117,8 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
protected void doLoadData(int offset, int count){
if(isInRecentMode()){
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().getRecentSearches(sr->{
if(getActivity()==null)
return;
unfilteredResults=sr;
prevDisplayItems=new ArrayList<>(displayItems);
onDataLoaded(sr, false);

View File

@@ -10,6 +10,7 @@ import org.joinmastodon.android.api.requests.trends.GetTrendingHashtags;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.HashtagChartView;
@@ -24,6 +25,7 @@ import me.grishka.appkit.views.UsableRecyclerView;
public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> implements ScrollableToTop{
private String accountID;
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_HASHTAGS);
public TrendingHashtagsFragment(){
super(10);
@@ -56,6 +58,7 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> impl
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, .5f, 16, 16));
bannerHelper.maybeAddBanner(contentWrap);
}
@Override

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments.onboarding;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.res.Configuration;
import android.os.Build;
@@ -13,6 +14,7 @@ import android.view.WindowInsets;
import android.widget.Button;
import android.widget.Toast;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
@@ -110,7 +112,11 @@ public class AccountActivationFragment extends AppKitFragment{
}
private void onButtonClick(){
startActivity(Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_EMAIL).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
try{
startActivity(Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_EMAIL).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
}catch(ActivityNotFoundException x){
Toast.makeText(getActivity(), R.string.no_app_to_handle_action, Toast.LENGTH_SHORT).show();
}
}
private void onBackButtonClick(){
@@ -131,6 +137,13 @@ public class AccountActivationFragment extends AppKitFragment{
}
private void tryGetAccount(){
if(AccountSessionManager.getInstance().tryGetAccount(accountID)==null){
uiHandler.removeCallbacks(pollRunnable);
getActivity().finish();
Intent intent=new Intent(getActivity(), MainActivity.class);
startActivity(intent);
return;
}
currentRequest=new GetOwnAccount()
.setCallback(new Callback<>(){
@Override

View File

@@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments.onboarding;
import android.app.ProgressDialog;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.LocaleList;
@@ -105,6 +106,8 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<CatalogInstance> result){
if(getActivity()==null)
return;
Map<String, List<CatalogInstance>> byLang=result.stream().collect(Collectors.groupingBy(ci->ci.language));
for(List<CatalogInstance> group:byLang.values()){
Collections.sort(group, (a, b)->{
@@ -347,7 +350,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
currentSearchQuery=searchEdit.getText().toString().toLowerCase();
updateFilteredList();
searchEdit.removeCallbacks(searchDebouncer);
Instance instance=instancesCache.get(currentSearchQuery);
Instance instance=instancesCache.get(normalizeInstanceDomain(currentSearchQuery));
if(instance==null){
showProgressDialog();
loadInstanceInfo(currentSearchQuery);
@@ -410,15 +413,27 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
instanceProgressDialog.show();
}
private void loadInstanceInfo(String _domain){
private String normalizeInstanceDomain(String _domain){
if(TextUtils.isEmpty(_domain))
return;
return null;
if(_domain.contains(":")){
try{
_domain=Uri.parse(_domain).getAuthority();
}catch(Exception ignore){}
if(TextUtils.isEmpty(_domain))
return null;
}
String domain;
try{
domain=IDN.toASCII(_domain);
}catch(IllegalArgumentException x){
return;
return null;
}
return domain;
}
private void loadInstanceInfo(String _domain){
String domain=normalizeInstanceDomain(_domain);
Instance cachedInstance=instancesCache.get(domain);
if(cachedInstance!=null){
for(CatalogInstance ci:filteredData){

View File

@@ -15,6 +15,7 @@ import android.widget.TextView;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
import org.joinmastodon.android.events.FinishReportFragmentsEvent;
@@ -60,6 +61,13 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
setRetainInstance(true);
setListLayoutId(R.layout.fragment_content_report_posts);
setLayout(R.layout.fragment_report_posts);
E.register(this);
}
@Override
public void onDestroy(){
E.unregister(this);
super.onDestroy();
}
@Override

View File

@@ -5,7 +5,9 @@ import android.os.Bundle;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.FinishReportFragmentsEvent;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.ReportReason;
import org.parceler.Parcels;
@@ -21,7 +23,10 @@ public class ReportReasonChoiceFragment extends BaseReportChoiceFragment{
protected void populateItems(){
items.add(new Item(getString(R.string.report_reason_personal), getString(R.string.report_reason_personal_subtitle), ReportReason.PERSONAL.name()));
items.add(new Item(getString(R.string.report_reason_spam), getString(R.string.report_reason_spam_subtitle), ReportReason.SPAM.name()));
items.add(new Item(getString(R.string.report_reason_violation), getString(R.string.report_reason_violation_subtitle), ReportReason.VIOLATION.name()));
Instance inst=AccountSessionManager.getInstance().getInstanceInfo(AccountSessionManager.getInstance().getAccount(accountID).domain);
if(inst!=null && inst.rules!=null && !inst.rules.isEmpty()){
items.add(new Item(getString(R.string.report_reason_violation), getString(R.string.report_reason_violation_subtitle), ReportReason.VIOLATION.name()));
}
items.add(new Item(getString(R.string.report_reason_other), getString(R.string.report_reason_other_subtitle), ReportReason.OTHER.name()));
}

View File

@@ -0,0 +1,14 @@
package org.joinmastodon.android.model;
public class CacheablePaginatedResponse<T> extends PaginatedResponse<T>{
private final boolean fromCache;
public CacheablePaginatedResponse(T items, String maxID, boolean fromCache){
super(items, maxID);
this.fromCache=fromCache;
}
public boolean isFromCache(){
return fromCache;
}
}

View File

@@ -0,0 +1,24 @@
package org.joinmastodon.android.model;
import android.net.Uri;
import java.util.ArrayList;
import java.util.Collection;
import androidx.annotation.NonNull;
public class HeaderPaginationList<T> extends ArrayList<T>{
public Uri nextPageUri, prevPageUri;
public HeaderPaginationList(int initialCapacity){
super(initialCapacity);
}
public HeaderPaginationList(){
super();
}
public HeaderPaginationList(@NonNull Collection<? extends T> c){
super(c);
}
}

View File

@@ -54,6 +54,7 @@ public class Status extends BaseModel implements DisplayItemsParent{
public boolean pinned;
public transient boolean spoilerRevealed;
public transient boolean hasGapAfter;
@Override
public void postprocess() throws ObjectValidationException{

View File

@@ -0,0 +1,249 @@
package org.joinmastodon.android.ui;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
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;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.SplashFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.List;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.BottomSheet;
import me.grishka.appkit.views.UsableRecyclerView;
public class AccountSwitcherSheet extends BottomSheet{
private final Activity activity;
private UsableRecyclerView list;
private List<WrappedAccount> accounts;
private ListImageLoaderWrapper imgLoader;
public AccountSwitcherSheet(@NonNull Activity activity){
super(activity);
this.activity=activity;
accounts=AccountSessionManager.getInstance().getLoggedInAccounts().stream().map(WrappedAccount::new).collect(Collectors.toList());
list=new UsableRecyclerView(activity);
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
list.setClipToPadding(false);
list.setLayoutManager(new LinearLayoutManager(activity));
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
View handle=new View(activity);
handle.setBackgroundResource(R.drawable.bg_bottom_sheet_handle);
adapter.addAdapter(new SingleViewRecyclerAdapter(handle));
adapter.addAdapter(new AccountsAdapter());
AccountViewHolder holder=new AccountViewHolder();
holder.more.setVisibility(View.GONE);
holder.currentIcon.setVisibility(View.GONE);
holder.name.setText(R.string.add_account);
holder.avatar.setScaleType(ImageView.ScaleType.CENTER);
holder.avatar.setImageResource(R.drawable.ic_fluent_add_circle_24_filled);
holder.avatar.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(activity, android.R.attr.textColorPrimary)));
adapter.addAdapter(new ClickableSingleViewRecyclerAdapter(holder.itemView, ()->{
Nav.go(activity, SplashFragment.class, null);
dismiss();
}));
list.setAdapter(adapter);
DividerItemDecoration divider=new DividerItemDecoration(activity, R.attr.colorPollVoted, .5f, 72, 16, DividerItemDecoration.NOT_FIRST);
divider.setDrawBelowLastItem(true);
list.addItemDecoration(divider);
FrameLayout content=new FrameLayout(activity);
content.setBackgroundResource(R.drawable.bg_bottom_sheet);
content.addView(list);
setContentView(content);
setNavigationBarBackground(new ColorDrawable(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground)), !UiUtils.isDarkTheme());
}
private void confirmLogOut(String accountID){
new M3AlertDialogBuilder(activity)
.setTitle(R.string.log_out)
.setMessage(R.string.confirm_log_out)
.setPositiveButton(R.string.log_out, (dialog, which) -> logOut(accountID))
.setNegativeButton(R.string.cancel, null)
.show();
}
private void logOut(String accountID){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Object result){
onLoggedOut(accountID);
}
@Override
public void onError(ErrorResponse error){
onLoggedOut(accountID);
}
})
.wrapProgress(activity, R.string.loading, false)
.exec(accountID);
}
private void onLoggedOut(String accountID){
AccountSessionManager.getInstance().removeAccount(accountID);
dismiss();
}
@Override
protected void onWindowInsetsUpdated(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29){
int tappableBottom=insets.getTappableElementInsets().bottom;
int insetBottom=insets.getSystemWindowInsetBottom();
if(tappableBottom==0 && insetBottom>0){
list.setPadding(0, 0, 0, V.dp(48)-insetBottom);
}else{
list.setPadding(0, 0, 0, V.dp(24));
}
}else{
list.setPadding(0, 0, 0, V.dp(24));
}
}
private class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
super(imgLoader);
}
@NonNull
@Override
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new AccountViewHolder();
}
@Override
public int getItemCount(){
return accounts.size();
}
@Override
public void onBindViewHolder(AccountViewHolder holder, int position){
holder.bind(accounts.get(position).session);
super.onBindViewHolder(holder, position);
}
@Override
public int getImageCountForItem(int position){
return 1;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
return accounts.get(position).req;
}
}
private class AccountViewHolder extends BindableViewHolder<AccountSession> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private final TextView name;
private final ImageView avatar;
private final ImageButton more;
private final View currentIcon;
private final PopupMenu menu;
public AccountViewHolder(){
super(activity, R.layout.item_account_switcher, list);
name=findViewById(R.id.name);
avatar=findViewById(R.id.avatar);
more=findViewById(R.id.more);
currentIcon=findViewById(R.id.current);
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
avatar.setClipToOutline(true);
menu=new PopupMenu(activity, more);
menu.inflate(R.menu.account_switcher);
menu.setOnMenuItemClickListener(item1 -> {
confirmLogOut(item.getID());
return true;
});
more.setOnClickListener(v->menu.show());
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(AccountSession item){
name.setText("@"+item.self.username+"@"+item.domain);
if(AccountSessionManager.getInstance().getLastActiveAccountID().equals(item.getID())){
more.setVisibility(View.GONE);
currentIcon.setVisibility(View.VISIBLE);
}else{
more.setVisibility(View.VISIBLE);
currentIcon.setVisibility(View.GONE);
}
menu.getMenu().findItem(R.id.log_out).setTitle(activity.getString(R.string.log_out_account, "@"+item.self.username));
UiUtils.enablePopupMenuIcons(activity, menu);
}
@Override
public void setImage(int index, Drawable image){
avatar.setImageDrawable(image);
if(image instanceof Animatable a)
a.start();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
@Override
public void onClick(){
AccountSessionManager.getInstance().setLastActiveAccountID(item.getID());
activity.finish();
activity.startActivity(new Intent(activity, MainActivity.class));
}
}
private static class WrappedAccount{
public final AccountSession session;
public final ImageLoaderRequest req;
public WrappedAccount(AccountSession session){
this.session=session;
req=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? session.self.avatar : session.self.avatarStatic, V.dp(50), V.dp(50));
}
}
}

View File

@@ -0,0 +1,34 @@
package org.joinmastodon.android.ui;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.views.UsableRecyclerView;
public class ClickableSingleViewRecyclerAdapter extends SingleViewRecyclerAdapter{
private final Runnable onClick;
public ClickableSingleViewRecyclerAdapter(View view, Runnable onClick){
super(view);
this.onClick=onClick;
}
@NonNull
@Override
public ViewViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new ClickableViewViewHolder(view);
}
public class ClickableViewViewHolder extends ViewViewHolder implements UsableRecyclerView.Clickable{
public ClickableViewViewHolder(@NonNull View itemView){
super(itemView);
}
@Override
public void onClick(){
onClick.run();
}
}
}

View File

@@ -18,6 +18,7 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{
private Paint paint=new Paint();
private int paddingStart, paddingEnd;
private Predicate<RecyclerView.ViewHolder> drawDividerPredicate;
private boolean drawBelowLastItem;
public static final Predicate<RecyclerView.ViewHolder> NOT_FIRST=vh->vh.getAbsoluteAdapterPosition()>0;
@@ -34,6 +35,10 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{
this.drawDividerPredicate=drawDividerPredicate;
}
public void setDrawBelowLastItem(boolean drawBelowLastItem){
this.drawBelowLastItem=drawBelowLastItem;
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
boolean isRTL=parent.getLayoutDirection()==View.LAYOUT_DIRECTION_RTL;
@@ -43,7 +48,7 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{
for(int i=0;i<parent.getChildCount();i++){
View child=parent.getChildAt(i);
int pos=parent.getChildAdapterPosition(child);
if(pos<totalItems-1 && (drawDividerPredicate==null || drawDividerPredicate.test(parent.getChildViewHolder(child)))){
if((drawBelowLastItem || pos<totalItems-1) && (drawDividerPredicate==null || drawDividerPredicate.test(parent.getChildViewHolder(child)))){
float y=Math.round(child.getY()+child.getHeight());
y-=(y-paint.getStrokeWidth()/2f)%1f; // Make sure the line aligns with the pixel grid
paint.setAlpha(Math.round(255f*child.getAlpha()));

View File

@@ -0,0 +1,87 @@
package org.joinmastodon.android.ui;
import android.app.Fragment;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.view.View;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import java.util.function.Supplier;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class SingleImagePhotoViewerListener implements PhotoViewer.Listener{
private final View sourceView, transformView;
private final int[] cornerRadius;
private final Runnable onDismissed;
private final Fragment parentFragment;
private final Supplier<Drawable> currentDrawableSupplier;
private final Runnable onStart, onEnd;
private float origAlpha;
public SingleImagePhotoViewerListener(View sourceView, View transformView, int[] cornerRadius, Fragment parentFragment, Runnable onDismissed, Supplier<Drawable> currentDrawableSupplier, Runnable onStart, Runnable onEnd){
this.sourceView=sourceView;
this.transformView=transformView;
this.cornerRadius=cornerRadius;
this.onDismissed=onDismissed;
this.parentFragment=parentFragment;
this.currentDrawableSupplier=currentDrawableSupplier;
this.onStart=onStart;
this.onEnd=onEnd;
if(cornerRadius!=null && cornerRadius.length!=4)
throw new IllegalArgumentException("Corner radius must be null or have length of 4");
}
@Override
public void setPhotoViewVisibility(int index, boolean visible){
transformView.setVisibility(visible ? View.VISIBLE : View.INVISIBLE);
}
@Override
public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){
int[] loc={0, 0};
sourceView.getLocationOnScreen(loc);
outRect.set(loc[0], loc[1], loc[0]+sourceView.getWidth(), loc[1]+sourceView.getHeight());
if(cornerRadius!=null)
System.arraycopy(cornerRadius, 0, outCornerRadius, 0, 4);
transformView.setTranslationZ(1);
if(onStart!=null)
onStart.run();
return true;
}
@Override
public void setTransitioningViewTransform(float translateX, float translateY, float scale){
transformView.setTranslationX(translateX);
transformView.setTranslationY(translateY);
transformView.setScaleX(scale);
transformView.setScaleY(scale);
}
@Override
public void endPhotoViewTransition(){
setTransitioningViewTransform(0f, 0f, 1f);
transformView.setTranslationZ(0);
if(onEnd!=null)
onEnd.run();
}
@Nullable
@Override
public Drawable getPhotoViewCurrentDrawable(int index){
return currentDrawableSupplier.get();
}
@Override
public void photoViewerDismissed(){
onDismissed.run();
}
@Override
public void onRequestPermissions(String[] permissions){
parentFragment.requestPermissions(permissions, PhotoViewer.PERMISSION_REQUEST);
}
}

View File

@@ -0,0 +1,112 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.text.style.TypefaceSpan;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.account_list.StatusFavoritesListFragment;
import org.joinmastodon.android.fragments.account_list.StatusReblogsListFragment;
import org.joinmastodon.android.fragments.account_list.StatusRelatedAccountListFragment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
import androidx.annotation.PluralsRes;
import me.grishka.appkit.Nav;
public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
public final Status status;
private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
public ExtendedFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status){
super(parentID, parentFragment);
this.status=status;
}
@Override
public Type getType(){
return Type.EXTENDED_FOOTER;
}
public static class Holder extends StatusDisplayItem.Holder<ExtendedFooterStatusDisplayItem>{
private final TextView reblogs, favorites, time;
private final View buttonsView;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_extended_footer, parent);
reblogs=findViewById(R.id.reblogs);
favorites=findViewById(R.id.favorites);
time=findViewById(R.id.timestamp);
buttonsView=findViewById(R.id.button_bar);
reblogs.setOnClickListener(v->startAccountListFragment(StatusReblogsListFragment.class));
favorites.setOnClickListener(v->startAccountListFragment(StatusFavoritesListFragment.class));
}
@Override
public void onBind(ExtendedFooterStatusDisplayItem item){
Status s=item.status;
if(s.favouritesCount>0){
favorites.setVisibility(View.VISIBLE);
favorites.setText(getFormattedPlural(R.plurals.x_favorites, s.favouritesCount));
}else{
favorites.setVisibility(View.GONE);
}
if(s.reblogsCount>0){
reblogs.setVisibility(View.VISIBLE);
reblogs.setText(getFormattedPlural(R.plurals.x_reblogs, s.reblogsCount));
}else{
reblogs.setVisibility(View.GONE);
}
if(s.favouritesCount==0 && s.reblogsCount==0){
buttonsView.setVisibility(View.GONE);
}else{
buttonsView.setVisibility(View.VISIBLE);
}
String timeStr=TIME_FORMATTER.format(item.status.createdAt.atZone(ZoneId.systemDefault()));
if(item.status.application!=null && !TextUtils.isEmpty(item.status.application.name)){
time.setText(item.parentFragment.getString(R.string.timestamp_via_app, timeStr, item.status.application.name));
}else{
time.setText(timeStr);
}
}
@Override
public boolean isEnabled(){
return false;
}
private SpannableStringBuilder getFormattedPlural(@PluralsRes int res, int quantity){
String str=item.parentFragment.getResources().getQuantityString(res, quantity, quantity);
String formattedNumber=String.format(Locale.getDefault(), "%,d", quantity);
int index=str.indexOf(formattedNumber);
SpannableStringBuilder ssb=new SpannableStringBuilder(str);
if(index>=0){
ssb.setSpan(new TypefaceSpan("sans-serif-medium"), index, index+formattedNumber.length(), 0);
ssb.setSpan(new ForegroundColorSpan(UiUtils.getThemeColor(item.parentFragment.getActivity(), android.R.attr.textColorPrimary)), index, index+formattedNumber.length(), 0);
}
return ssb;
}
private void startAccountListFragment(Class<? extends StatusRelatedAccountListFragment> cls){
Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("status", Parcels.wrap(item.status));
Nav.go(item.parentFragment.getActivity(), cls, args);
}
}
}

View File

@@ -29,6 +29,7 @@ import me.grishka.appkit.utils.V;
public class FooterStatusDisplayItem extends StatusDisplayItem{
public final Status status;
private final String accountID;
public boolean hideCounts;
public FooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status, String accountID){
super(parentID, parentFragment);
@@ -91,7 +92,7 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
private void bindButton(TextView btn, int count){
if(count>0){
if(count>0 && !item.hideCounts){
btn.setText(DecimalFormat.getIntegerInstance().format(count));
btn.setCompoundDrawablePadding(V.dp(8));
}else{

View File

@@ -0,0 +1,48 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.ui.drawables.SawtoothTearDrawable;
// Mind the gap!
public class GapStatusDisplayItem extends StatusDisplayItem{
public boolean loading;
public GapStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment){
super(parentID, parentFragment);
}
@Override
public Type getType(){
return Type.GAP;
}
public static class Holder extends StatusDisplayItem.Holder<GapStatusDisplayItem>{
public final ProgressBar progress;
public final TextView text;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_gap, parent);
progress=findViewById(R.id.progress);
text=findViewById(R.id.text);
itemView.setForeground(new SawtoothTearDrawable(context));
}
@Override
public void onBind(GapStatusDisplayItem item){
text.setVisibility(item.loading ? View.GONE : View.VISIBLE);
progress.setVisibility(item.loading ? View.VISIBLE : View.GONE);
}
@Override
public void onClick(){
item.parentFragment.onGapClick(this);
}
}
}

View File

@@ -250,6 +250,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
Menu menu=optionsMenu.getMenu();
boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account);
menu.findItem(R.id.delete).setVisible(item.status!=null && isOwnPost);
menu.findItem(R.id.open_in_browser).setVisible(item.status!=null);
MenuItem blockDomain=menu.findItem(R.id.block_domain);
MenuItem mute=menu.findItem(R.id.mute);
MenuItem block=menu.findItem(R.id.block);

View File

@@ -63,6 +63,8 @@ public abstract class StatusDisplayItem{
case ACCOUNT_CARD -> new AccountCardStatusDisplayItem.Holder(activity, parent);
case ACCOUNT -> new AccountStatusDisplayItem.Holder(activity, parent);
case HASHTAG -> new HashtagStatusDisplayItem.Holder(activity, parent);
case GAP -> new GapStatusDisplayItem.Holder(activity, parent);
case EXTENDED_FOOTER -> new ExtendedFooterStatusDisplayItem.Holder(activity, parent);
};
}
@@ -112,6 +114,8 @@ public abstract class StatusDisplayItem{
}
if(addFooter){
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
if(status.hasGapAfter)
items.add(new GapStatusDisplayItem(parentID, fragment));
}
int i=1;
for(StatusDisplayItem item:items){
@@ -142,7 +146,9 @@ public abstract class StatusDisplayItem{
FOOTER,
ACCOUNT_CARD,
ACCOUNT,
HASHTAG
HASHTAG,
GAP,
EXTENDED_FOOTER
}
public static abstract class Holder<T extends StatusDisplayItem> extends BindableViewHolder<T> implements UsableRecyclerView.DisableableClickable{

View File

@@ -0,0 +1,107 @@
package org.joinmastodon.android.ui.drawables;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.drawable.Drawable;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class SawtoothTearDrawable extends Drawable{
private final Paint topPaint, bottomPaint;
private static final int TOP_SAWTOOTH_HEIGHT=5;
private static final int BOTTOM_SAWTOOTH_HEIGHT=3;
private static final int STROKE_WIDTH=2;
private static final int SAWTOOTH_PERIOD=14;
public SawtoothTearDrawable(Context context){
topPaint=makeShaderPaint(makeSawtoothTexture(context, TOP_SAWTOOTH_HEIGHT, SAWTOOTH_PERIOD, false, STROKE_WIDTH));
bottomPaint=makeShaderPaint(makeSawtoothTexture(context, BOTTOM_SAWTOOTH_HEIGHT, SAWTOOTH_PERIOD, true, STROKE_WIDTH));
Matrix matrix=new Matrix();
//noinspection IntegerDivisionInFloatingPointContext
matrix.setTranslate(V.dp(SAWTOOTH_PERIOD/2), 0);
bottomPaint.getShader().setLocalMatrix(matrix);
}
private Bitmap makeSawtoothTexture(Context context, int height, int period, boolean fillBottom, int strokeWidth){
int actualStrokeWidth=V.dp(strokeWidth);
int actualPeriod=V.dp(period);
int actualHeight=V.dp(height);
Bitmap bitmap=Bitmap.createBitmap(actualPeriod, actualHeight+actualStrokeWidth*2, Bitmap.Config.ARGB_8888);
Canvas c=new Canvas(bitmap);
Path path=new Path();
//noinspection SuspiciousNameCombination
path.moveTo(-actualPeriod/2f, actualStrokeWidth);
path.lineTo(0, actualHeight+actualStrokeWidth);
//noinspection SuspiciousNameCombination
path.lineTo(actualPeriod/2f, actualStrokeWidth);
path.lineTo(actualPeriod, actualHeight+actualStrokeWidth);
//noinspection SuspiciousNameCombination
path.lineTo(actualPeriod*1.5f, actualStrokeWidth);
if(fillBottom){
path.lineTo(actualPeriod*1.5f, actualHeight*20);
path.lineTo(-actualPeriod/2f, actualHeight*20);
}else{
path.lineTo(actualPeriod*1.5f, -actualHeight);
path.lineTo(-actualPeriod/2f, -actualHeight);
}
path.close();
Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorWindowBackground));
c.drawPath(path, paint);
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorPollVoted));
paint.setStrokeWidth(actualStrokeWidth);
paint.setStyle(Paint.Style.STROKE);
c.drawPath(path, paint);
return bitmap;
}
private Paint makeShaderPaint(Bitmap bitmap){
BitmapShader shader=new BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP);
Paint paint=new Paint();
paint.setShader(shader);
return paint;
}
@Override
public void draw(@NonNull Canvas canvas){
int strokeWidth=V.dp(STROKE_WIDTH);
Rect bounds=getBounds();
canvas.save();
canvas.translate(bounds.left, bounds.top);
canvas.drawRect(0, 0, bounds.width(), V.dp(TOP_SAWTOOTH_HEIGHT)+strokeWidth*2, topPaint);
int bottomHeight=V.dp(BOTTOM_SAWTOOTH_HEIGHT)+strokeWidth*2;
canvas.translate(0, bounds.height()-bottomHeight);
canvas.drawRect(0, 0, bounds.width(), bottomHeight, bottomPaint);
canvas.restore();
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSLUCENT;
}
}

View File

@@ -133,18 +133,18 @@ public class PhotoViewer implements ZoomPanView.Listener{
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29){
DisplayCutout cutout=insets.getDisplayCutout();
Insets tappable=insets.getTappableElementInsets();
if(cutout!=null){
// Make controls extend beneath the cutout, and replace insets to avoid cutout insets being filled with "navigation bar color"
Insets tappable=insets.getTappableElementInsets();
int leftInset=Math.max(0, cutout.getSafeInsetLeft()-tappable.left);
int rightInset=Math.max(0, cutout.getSafeInsetRight()-tappable.right);
insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom);
toolbarWrap.setPadding(leftInset, 0, rightInset, 0);
videoControls.setPadding(leftInset, 0, rightInset, 0);
}else{
toolbarWrap.setPadding(0, 0, 0, 0);
videoControls.setPadding(0, 0, 0, 0);
}
insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom);
}
uiOverlay.dispatchApplyWindowInsets(insets);
int bottomInset=insets.getSystemWindowInsetBottom();
@@ -514,7 +514,11 @@ public class PhotoViewer implements ZoomPanView.Listener{
}
private void saveViaDownloadManager(Attachment att){
DownloadManager.Request req=new DownloadManager.Request(Uri.parse(att.url));
Uri uri=Uri.parse(att.url);
DownloadManager.Request req=new DownloadManager.Request(uri);
req.allowScanningByMediaScanner();
req.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
req.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, uri.getLastPathSegment());
activity.getSystemService(DownloadManager.class).enqueue(req);
Toast.makeText(activity, R.string.downloading, Toast.LENGTH_SHORT).show();
}

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.util.AttributeSet;
@@ -54,6 +55,9 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
private float lastFlingVelocityY;
private float backgroundAlphaForTransition=1f;
private boolean forceUpdateLayout;
private int[] transitionCornerRadius;
private Path transitionClipPath=new Path();
private float[] tmpFloatArray=new float[8];
private static final String TAG="ZoomPanView";
@@ -148,10 +152,25 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
child.getMatrix().mapRect(tmpRect2);
tmpRect2.offset(child.getLeft(), child.getTop());
canvas.save();
canvas.clipRect(interpolate(tmpRect2.left, tmpRect.left, cropAnimationValue),
interpolate(tmpRect2.top, tmpRect.top, cropAnimationValue),
interpolate(tmpRect2.right, tmpRect.right, cropAnimationValue),
interpolate(tmpRect2.bottom, tmpRect.bottom, cropAnimationValue));
if(transitionCornerRadius!=null){
float radiusScale=child.getScaleX();
tmpFloatArray[0]=tmpFloatArray[1]=(float)transitionCornerRadius[0]*radiusScale*(1f-cropAnimationValue);
tmpFloatArray[2]=tmpFloatArray[3]=(float)transitionCornerRadius[1]*radiusScale*(1f-cropAnimationValue);
tmpFloatArray[4]=tmpFloatArray[5]=(float)transitionCornerRadius[2]*radiusScale*(1f-cropAnimationValue);
tmpFloatArray[6]=tmpFloatArray[7]=(float)transitionCornerRadius[3]*radiusScale*(1f-cropAnimationValue);
transitionClipPath.rewind();
transitionClipPath.addRoundRect(interpolate(tmpRect2.left, tmpRect.left, cropAnimationValue),
interpolate(tmpRect2.top, tmpRect.top, cropAnimationValue),
interpolate(tmpRect2.right, tmpRect.right, cropAnimationValue),
interpolate(tmpRect2.bottom, tmpRect.bottom, cropAnimationValue),
tmpFloatArray, Path.Direction.CW);
canvas.clipPath(transitionClipPath);
}else{
canvas.clipRect(interpolate(tmpRect2.left, tmpRect.left, cropAnimationValue),
interpolate(tmpRect2.top, tmpRect.top, cropAnimationValue),
interpolate(tmpRect2.right, tmpRect.right, cropAnimationValue),
interpolate(tmpRect2.bottom, tmpRect.bottom, cropAnimationValue));
}
boolean res=super.drawChild(canvas, child, drawingTime);
canvas.restore();
return res;
@@ -189,6 +208,18 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
return initialScale;
}
private void validateAndSetCornerRadius(int[] cornerRadius){
transitionCornerRadius=null;
if(cornerRadius!=null && cornerRadius.length==4){
for(int corner:cornerRadius){
if(corner>0){
transitionCornerRadius=cornerRadius;
break;
}
}
}
}
public void animateIn(Rect rect, int[] cornerRadius){
int[] loc={0, 0};
getLocationOnScreen(loc);
@@ -204,6 +235,7 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
animatingTransition=true;
matrix.getValues(matrixValues);
validateAndSetCornerRadius(cornerRadius);
child.setAlpha(0f);
setupAndStartTransitionAnim(new SpringAnimation(this, CROP_AND_FADE, 1f).setMinimumVisibleChange(DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE));
@@ -233,6 +265,7 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
animatingTransition=true;
dismissAfterTransition=true;
rawCropAndFadeValue=1f;
validateAndSetCornerRadius(cornerRadius);
setupAndStartTransitionAnim(new SpringAnimation(this, CROP_AND_FADE, 0f).setMinimumVisibleChange(DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE));
setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.SCALE_X, initialScale));

View File

@@ -0,0 +1,64 @@
package org.joinmastodon.android.ui.utils;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import me.grishka.appkit.utils.CubicBezierInterpolator;
public class DiscoverInfoBannerHelper{
private View banner;
private final BannerType type;
public DiscoverInfoBannerHelper(BannerType type){
this.type=type;
}
private SharedPreferences getPrefs(){
return MastodonApp.context.getSharedPreferences("onboarding", Context.MODE_PRIVATE);
}
public void maybeAddBanner(FrameLayout view){
if(!getPrefs().getBoolean("bannerHidden_"+type, false)){
((Activity)view.getContext()).getLayoutInflater().inflate(R.layout.discover_info_banner, view);
banner=view.findViewById(R.id.discover_info_banner);
view.findViewById(R.id.banner_dismiss).setOnClickListener(this::onDismissClick);
TextView text=view.findViewById(R.id.banner_text);
text.setText(switch(type){
case TRENDING_POSTS -> R.string.trending_posts_info_banner;
case TRENDING_HASHTAGS -> R.string.trending_hashtags_info_banner;
case TRENDING_LINKS -> R.string.trending_links_info_banner;
case LOCAL_TIMELINE -> R.string.local_timeline_info_banner;
});
}
}
private void onDismissClick(View v){
if(banner==null)
return;
View _banner=banner;
banner.animate()
.alpha(0)
.setDuration(200)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.withEndAction(()->((ViewGroup)_banner.getParent()).removeView(_banner))
.start();
getPrefs().edit().putBoolean("bannerHidden_"+type, true).apply();
banner=null;
}
public enum BannerType{
TRENDING_POSTS,
TRENDING_HASHTAGS,
TRENDING_LINKS,
LOCAL_TIMELINE,
// ACCOUNTS
}
}

View File

@@ -2,6 +2,7 @@ package org.joinmastodon.android.ui.utils;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
@@ -10,6 +11,7 @@ import android.content.res.TypedArray;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Typeface;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.InsetDrawable;
@@ -28,6 +30,7 @@ import android.webkit.MimeTypeMap;
import android.widget.Button;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toast;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
@@ -86,13 +89,17 @@ public class UiUtils{
private UiUtils(){}
public static void launchWebBrowser(Context context, String url){
if(GlobalUserPreferences.useCustomTabs){
new CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
.launchUrl(context, Uri.parse(url));
}else{
context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
try{
if(GlobalUserPreferences.useCustomTabs){
new CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
.launchUrl(context, Uri.parse(url));
}else{
context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
}
}catch(ActivityNotFoundException x){
Toast.makeText(context, R.string.no_app_to_handle_action, Toast.LENGTH_SHORT).show();
}
}
@@ -141,12 +148,15 @@ public class UiUtils{
@SuppressLint("DefaultLocale")
public static String abbreviateNumber(int n){
if(n<1000)
if(n<1000){
return String.format("%,d", n);
else if(n<1_000_000)
return String.format("%,.1fK", n/1000f);
else
return String.format("%,.1fM", n/1_000_000f);
}else if(n<1_000_000){
float a=n/1000f;
return a>99f ? String.format("%,dK", (int)Math.floor(a)) : String.format("%,.1fK", a);
}else{
float a=n/1_000_000f;
return a>99f ? String.format("%,dM", (int)Math.floor(a)) : String.format("%,.1fM", n/1_000_000f);
}
}
/**
@@ -182,7 +192,7 @@ public class UiUtils{
String name=cursor.getString(0);
if(name!=null)
return name;
}
}catch(Throwable ignore){}
}
return uri.getLastPathSegment();
}
@@ -340,13 +350,38 @@ public class UiUtils{
}
public static void setRelationshipToActionButton(Relationship relationship, Button button){
boolean secondaryStyle;
if(relationship.blocking){
button.setText(R.string.button_blocked);
}else if(relationship.muting){
button.setText(R.string.button_muted);
secondaryStyle=true;
}else if(relationship.blockedBy){
button.setText(R.string.button_follow);
secondaryStyle=false;
}else if(relationship.requested){
button.setText(R.string.button_follow_pending);
secondaryStyle=true;
}else if(!relationship.following){
button.setText(relationship.followedBy ? R.string.follow_back : R.string.button_follow);
secondaryStyle=false;
}else{
button.setText(relationship.following ? R.string.button_following : R.string.button_follow);
button.setText(R.string.button_following);
secondaryStyle=true;
}
button.setEnabled(!relationship.blockedBy);
int attr=secondaryStyle ? R.attr.secondaryButtonStyle : android.R.attr.buttonStyle;
TypedArray ta=button.getContext().obtainStyledAttributes(new int[]{attr});
int styleRes=ta.getResourceId(0, 0);
ta.recycle();
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});
if(relationship.blocking)
button.setTextColor(button.getResources().getColorStateList(R.color.error_600));
else
button.setTextColor(ta.getColorStateList(0));
ta.recycle();
}
public static void performAccountAction(Activity activity, Account account, String accountID, Relationship relationship, Button button, Consumer<Boolean> progressCallback, Consumer<Relationship> resultCallback){
@@ -356,7 +391,7 @@ public class UiUtils{
confirmToggleMuteUser(activity, accountID, account, true, resultCallback);
}else{
progressCallback.accept(true);
new SetAccountFollowed(account.id, !relationship.following, true)
new SetAccountFollowed(account.id, !relationship.following && !relationship.requested, true)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){

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