Compare commits

...

194 Commits

Author SHA1 Message Date
sk
28f6d00530 Merge remote-tracking branch 'upstream/master' into upstream-release 2023-01-21 02:20:20 +01:00
Grishka
6e718d6765 Save last seen home timeline post via markers API 2023-01-18 20:29:49 +03:00
Grishka
b26d491eda remove log 2023-01-18 20:10:03 +03:00
Grishka
abdbab9d7b Allow viewing alt text on images
closes #100
2023-01-18 20:09:27 +03:00
Grishka
af1c7194e6 Workaround to fix #497 2023-01-18 18:41:48 +03:00
sk
a3b6decb72 Merge remote-tracking branch 'upstream/master' into upstream-release 2023-01-17 02:23:25 +01:00
Gregory K
ab3a98fd60 Merge pull request #439 from Poussinou/patch-1
Update README.md
2023-01-15 22:15:30 +03:00
Grishka
6e6fdbccd5 Fix #484 2023-01-15 11:40:06 +03:00
Grishka
1764e5f3d1 Paginate trending posts 2023-01-15 11:09:53 +03:00
Grishka
836c493951 Fix #499 2023-01-12 00:40:50 +03:00
Grishka
d667b8fa98 Fix #498 2023-01-11 13:06:40 +03:00
Gregory K
6a1032cd61 Merge pull request #492 from tylersaunders/mastodon-photopicker
Update ComposeFragment to use the photopicker.
2023-01-11 12:49:19 +03:00
Tyler Saunders
557d535e5a Update ComposeFragment to use the photopicker.
The android platform has a great photopicker, and this change checks
for the current device's sdk version, and uses the photopicker if it's
available on the device.

For pre Android R sdkrev2 devices, the experience remains unchanged.
2023-01-03 17:21:22 +00:00
Gregory K
60517b00f3 Merge pull request #491 from mishnz/master
Second fix for MIME64 inconsistency in serverKey.
2023-01-02 14:23:23 +03:00
mishnz
faf5e8e82b Merge branch 'master' of https://github.com/mishnz/mastodon-android 2023-01-03 00:16:12 +13:00
mishnz
7264982761 serverKey assignment was missing, corrected. 2023-01-03 00:15:45 +13:00
mishnz
fedf74258f Merge branch 'mastodon:master' into master 2023-01-03 00:06:53 +13:00
mishnz
def4960be6 Second fix for MIME64 inconsistency in serverKey.
The previous fix https://github.com/mastodon/mastodon-android/pull/486 would break any connections to any instances using WEB_SAFE MIME64 encoding on the serverKey, which actually appears to be the usual case.
This update reverts to the previous logic, but also converts standard MIME64 characters ('/' and '+') to their WEB_SAFE equivalents.
This ensures the standard case of WEB_SAFE BASE64 serverKeys and the anomolous case of DEFAULT BASE64 keys both work.
2023-01-02 23:56:52 +13:00
Eugen Rochko
525cc69c70 Merge pull request #486 from mishnz/master
The Mastodon server does not currently use URL_SAFE encoding on its s…
2023-01-01 10:01:17 +01:00
mishnz
7ed1b164b5 The Mastodon server does not currently use URL_SAFE encoding on its serverKey. Using URL_SAFE in this client means the client will crash for any server that uses a key that generates a Mime64 string containing a "+" or "/". This change removes the URL_SAFE logic. See: https://github.com/mastodon/mastodon-android/issues/483 2023-01-01 18:50:09 +13:00
sk
aafb08d7b3 Merge remote-tracking branch 'upstream/master' into upstream-release 2022-12-22 01:32:45 +01:00
Gregory K
a5fa44213d Merge pull request #474 from sk22/fix-wrong-visibility-on-edit
Only load default status visibility if not editing
2022-12-21 11:25:14 +03:00
sk
68e9d9d91c only load default visibility if not editing
fix mastodon#306
2022-12-21 09:24:09 +01:00
Grishka
d9df150cf8 Fix #472 2022-12-20 12:14:56 +03:00
Grishka
6f2e5a63d7 This is officially the first Xiaomi workaround in this app 🎉
#469
2022-12-19 20:27:56 +03:00
sk
2433d457b6 Merge remote-tracking branch 'upstream/master' into upstream-release 2022-12-17 21:50:33 +01:00
Grishka
e0febda372 Minor fixes 2022-12-15 20:16:32 +03:00
Grishka
069d141451 Fix crash in ComposeFragment::onSaveInstanceState in release builds
fixes #452, fixes #453, fixes #457
2022-12-15 20:02:26 +03:00
Grishka
166401ea18 Signup flow redesign 2022-12-15 19:41:38 +03:00
sk
9ad0bc2454 fix upstream branch 2022-12-09 14:23:40 +01:00
sk
b79c769001 Merge remote-tracking branch 'upstream/master' into upstream-release 2022-12-09 03:16:47 +01:00
Gregory K
b79ba71228 Merge pull request #445 from sk22/better-inline-emoji-search
Improve inline emoji search
2022-12-08 23:22:24 +03:00
Grishka
2903874dbc Splash fragment redesign 2022-12-08 23:22:10 +03:00
Poussinou
0dcdda75be fix typo in README 2022-12-08 12:45:28 +09:30
Grishka
202a5f9581 Fix #443 again 2022-12-08 01:48:23 +03:00
sk
622c6d503d improve inline emoji search
closes #131
closes mastodon#219
2022-12-07 16:34:52 +01:00
Grishka
e10faeefc4 Update languages 2022-12-06 19:18:51 +03:00
Grishka
65dbbb3d61 Fix #443 2022-12-06 18:44:17 +03:00
Grishka
fa69868ca1 Merge branch 'l10n_master' 2022-12-06 18:33:35 +03:00
Eugen Rochko
9c18de7b90 New translations strings.xml (Icelandic) 2022-12-06 15:26:12 +01:00
Eugen Rochko
61bd19f6ff New translations strings.xml (Icelandic) 2022-12-06 14:24:50 +01:00
Eugen Rochko
ba0689aef7 New translations strings.xml (German) 2022-12-05 21:20:13 +01:00
Eugen Rochko
ad54e6bb4b New translations full_description.txt (German) 2022-12-05 20:15:33 +01:00
Eugen Rochko
f15fcb43da New translations strings.xml (German) 2022-12-05 20:15:32 +01:00
Eugen Rochko
f2557b7815 New translations strings.xml (Thai) 2022-12-05 15:50:40 +01:00
Eugen Rochko
a2726f5b61 New translations strings.xml (Vietnamese) 2022-12-05 15:50:39 +01:00
Eugen Rochko
a30f5bdee8 New translations strings.xml (Russian) 2022-12-05 12:12:10 +01:00
Eugen Rochko
4cef005286 New translations strings.xml (German) 2022-12-04 21:26:22 +01:00
Eugen Rochko
58a05681fe New translations strings.xml (Turkish) 2022-12-04 20:14:06 +01:00
Eugen Rochko
2589faf499 New translations full_description.txt (Hungarian) 2022-12-04 12:08:30 +01:00
Eugen Rochko
a5bdf34289 New translations strings.xml (French) 2022-12-04 10:30:25 +01:00
Eugen Rochko
09fdd7f492 New translations strings.xml (Turkish) 2022-12-04 06:23:19 +01:00
Eugen Rochko
519d8b887d New translations strings.xml (Filipino) 2022-12-03 23:52:40 +01:00
Eugen Rochko
a2f2263bf7 New translations strings.xml (Swedish) 2022-12-03 23:52:39 +01:00
Eugen Rochko
5b73b10b34 New translations strings.xml (Russian) 2022-12-03 23:52:38 +01:00
Eugen Rochko
b7a4364a28 New translations strings.xml (Portuguese) 2022-12-03 23:52:37 +01:00
Eugen Rochko
3f075aff7b New translations strings.xml (Korean) 2022-12-03 23:52:37 +01:00
Eugen Rochko
f4c33a5970 New translations strings.xml (Japanese) 2022-12-03 23:52:36 +01:00
Eugen Rochko
809af0ec18 New translations strings.xml (Italian) 2022-12-03 23:52:35 +01:00
Eugen Rochko
4ee640e072 New translations strings.xml (Armenian) 2022-12-03 23:52:34 +01:00
Eugen Rochko
1cbf310555 New translations strings.xml (Hebrew) 2022-12-03 23:52:33 +01:00
Eugen Rochko
f1fdc8aa43 New translations strings.xml (Turkish) 2022-12-03 23:52:32 +01:00
Eugen Rochko
d696daece3 New translations strings.xml (Czech) 2022-12-03 23:52:29 +01:00
Eugen Rochko
967bb09282 New translations strings.xml (Catalan) 2022-12-03 23:52:28 +01:00
Eugen Rochko
136d910b3b New translations strings.xml (Spanish) 2022-12-03 23:52:27 +01:00
Eugen Rochko
51eb48a455 New translations strings.xml (French) 2022-12-03 23:52:26 +01:00
Eugen Rochko
6ee8afcf96 New translations strings.xml (Arabic) 2022-12-03 23:52:25 +01:00
Eugen Rochko
a59f2d4609 New translations strings.xml (German) 2022-12-03 23:52:24 +01:00
Eugen Rochko
b75d871837 New translations strings.xml (Chinese Traditional) 2022-12-03 23:52:23 +01:00
Eugen Rochko
c72f93b990 New translations strings.xml (Basque) 2022-12-03 23:52:22 +01:00
Eugen Rochko
586d337ead New translations strings.xml (Polish) 2022-12-03 23:52:21 +01:00
Eugen Rochko
d84e10a22e New translations strings.xml (Ukrainian) 2022-12-03 23:52:20 +01:00
Eugen Rochko
351ec89207 New translations strings.xml (Vietnamese) 2022-12-03 23:52:19 +01:00
Eugen Rochko
7db7bf0220 New translations strings.xml (Hungarian) 2022-12-03 23:52:18 +01:00
Eugen Rochko
a9764c4f46 New translations strings.xml (Icelandic) 2022-12-03 23:52:17 +01:00
Eugen Rochko
a430b6a280 New translations strings.xml (Slovenian) 2022-12-03 23:52:16 +01:00
Eugen Rochko
6a01124d13 New translations strings.xml (Romanian) 2022-12-03 23:52:14 +01:00
Eugen Rochko
2843e445e2 New translations strings.xml (Chinese Simplified) 2022-12-03 23:52:12 +01:00
Eugen Rochko
5c947d14b2 New translations strings.xml (Scottish Gaelic) 2022-12-03 23:52:11 +01:00
Eugen Rochko
590adba3e3 New translations strings.xml (Indonesian) 2022-12-03 23:52:10 +01:00
Eugen Rochko
efee249173 New translations strings.xml (Dutch) 2022-12-03 23:52:09 +01:00
Eugen Rochko
6d2ed27364 New translations strings.xml (Kabyle) 2022-12-03 23:52:08 +01:00
Eugen Rochko
55716d742f New translations strings.xml (Bosnian) 2022-12-03 23:52:06 +01:00
Eugen Rochko
e4555da735 New translations strings.xml (Croatian) 2022-12-03 23:52:05 +01:00
Eugen Rochko
8b4b99bec7 New translations strings.xml (Thai) 2022-12-03 23:52:04 +01:00
Eugen Rochko
5de4b19969 New translations strings.xml (Galician) 2022-12-03 23:52:03 +01:00
Eugen Rochko
a9460f401e New translations strings.xml (Portuguese, Brazilian) 2022-12-03 23:52:01 +01:00
Grishka
012cca550e New login screen 2022-12-04 01:34:03 +03:00
Eugen Rochko
0c743db412 New translations strings.xml (German) 2022-12-03 22:56:40 +01:00
Eugen Rochko
b819ee7d6d New translations strings.xml (German) 2022-12-03 21:24:27 +01:00
Poussinou
e9fe4a82df Update README.md 2022-12-04 05:26:19 +09:30
Eugen Rochko
e7e3a249b5 New translations strings.xml (German) 2022-12-03 20:28:32 +01:00
Eugen Rochko
980c580b55 New translations full_description.txt (Hungarian) 2022-12-03 18:23:40 +01:00
Eugen Rochko
e23c530e74 New translations short_description.txt (Hungarian) 2022-12-03 17:20:17 +01:00
Eugen Rochko
a64caccca2 New translations full_description.txt (Hungarian) 2022-12-03 17:20:16 +01:00
Eugen Rochko
726ec7159c New translations strings.xml (Kabyle) 2022-12-03 07:19:07 +01:00
Eugen Rochko
e74256ef6f New translations strings.xml (Polish) 2022-12-02 22:37:01 +01:00
Eugen Rochko
a18718ca81 New translations strings.xml (Chinese Traditional) 2022-12-02 17:34:15 +01:00
Eugen Rochko
5a9bc0e269 New translations full_description.txt (German) 2022-12-02 16:33:35 +01:00
Eugen Rochko
2d39c62ff0 New translations strings.xml (German) 2022-12-02 16:33:34 +01:00
Eugen Rochko
0da4f79413 New translations strings.xml (Hungarian) 2022-12-02 14:17:58 +01:00
Eugen Rochko
2bdef776a2 New translations strings.xml (Hungarian) 2022-12-02 13:08:55 +01:00
Eugen Rochko
a57ad67308 New translations strings.xml (Italian) 2022-12-01 22:01:18 +01:00
Eugen Rochko
e63d04cea9 New translations strings.xml (Basque) 2022-12-01 22:01:17 +01:00
Eugen Rochko
cf48cb6f75 New translations strings.xml (Hungarian) 2022-12-01 19:43:19 +01:00
Eugen Rochko
542e53cf6a New translations strings.xml (Basque) 2022-12-01 19:43:18 +01:00
Eugen Rochko
bab1d40038 New translations strings.xml (Filipino) 2022-12-01 18:41:53 +01:00
sk
b7dd376066 Revert "update changelog"
This reverts commit 46081bed3e.
2022-12-01 16:37:39 +01:00
sk
46081bed3e update changelog 2022-12-01 16:21:19 +01:00
Eugen Rochko
18f605e5c5 New translations strings.xml (Catalan) 2022-11-30 22:54:44 +01:00
Eugen Rochko
cd8a80a6a1 New translations strings.xml (Slovenian) 2022-11-29 22:06:16 +01:00
Eugen Rochko
3ce8aa7894 New translations strings.xml (Hungarian) 2022-11-29 21:05:04 +01:00
Eugen Rochko
b356794da9 New translations strings.xml (Icelandic) 2022-11-29 18:17:42 +01:00
Eugen Rochko
afe8f6cf6a New translations strings.xml (Vietnamese) 2022-11-29 14:58:28 +01:00
Eugen Rochko
ed0df82fe9 New translations strings.xml (Thai) 2022-11-29 14:03:11 +01:00
Eugen Rochko
d3bc7a9790 New translations strings.xml (Japanese) 2022-11-29 05:13:57 +01:00
sk
7053f4acb6 add custom ID and version name 2022-11-28 18:56:28 +01:00
sk
64e85f6992 Merge remote-tracking branch 'upstream/master' into upstream-release 2022-11-28 18:46:43 +01:00
Eugen Rochko
633c0f870d New translations strings.xml (Galician) 2022-11-28 08:56:16 +01:00
Eugen Rochko
f9fe7819f9 New translations strings.xml (Hungarian) 2022-11-28 00:06:00 +01:00
Eugen Rochko
f3d13545e7 New translations strings.xml (Hungarian) 2022-11-27 23:06:27 +01:00
Eugen Rochko
f6b77777b5 New translations full_description.txt (Czech) 2022-11-27 20:34:15 +01:00
Eugen Rochko
340990fbd9 New translations strings.xml (Czech) 2022-11-27 20:34:14 +01:00
Eugen Rochko
a7687f8e35 New translations strings.xml (Czech) 2022-11-27 19:31:30 +01:00
Eugen Rochko
52aa4a5289 New translations strings.xml (Czech) 2022-11-27 17:43:12 +01:00
Eugen Rochko
268accea14 New translations strings.xml (Filipino) 2022-11-27 16:43:39 +01:00
Eugen Rochko
101cde4d84 New translations strings.xml (French) 2022-11-27 16:43:37 +01:00
Eugen Rochko
8863446f6a New translations strings.xml (Czech) 2022-11-27 15:03:46 +01:00
Eugen Rochko
28a0824f6b New translations strings.xml (Czech) 2022-11-27 13:59:12 +01:00
Grishka
4b16262a1a Sync last seen notification ID with server 2022-11-27 13:39:50 +03:00
Eugen Rochko
b1f9d0516d New translations strings.xml (Dutch) 2022-11-27 11:30:42 +01:00
Grishka
10e7cbf022 Merge branch 'add/verified-profile-fields' 2022-11-27 13:15:13 +03:00
Grishka
531b8ead04 Make verified fields more like on iOS 2022-11-27 13:14:31 +03:00
Grishka
4b2c94ab52 Implement bookmarks and add favorites list
Closes #22, at last
2022-11-27 12:43:07 +03:00
Eugen Rochko
5b21747d5d New translations strings.xml (Filipino) 2022-11-27 10:01:36 +01:00
Grishka
a98becf2f4 Update splash logo to purple 2022-11-27 11:40:55 +03:00
Eugen Rochko
9fda48cff0 New translations strings.xml (Filipino) 2022-11-27 09:04:16 +01:00
Grishka
54f9eace67 Fix #422 2022-11-27 10:02:47 +03:00
Eugen Rochko
0e6f3df212 New translations strings.xml (Galician) 2022-11-27 07:48:06 +01:00
Eugen Rochko
a8c3f1555e New translations title.txt (Filipino) 2022-11-26 22:17:53 +01:00
Eugen Rochko
cd797a637b New translations short_description.txt (Filipino) 2022-11-26 22:17:53 +01:00
Eugen Rochko
53b2eb59d3 New translations full_description.txt (Filipino) 2022-11-26 22:17:52 +01:00
Eugen Rochko
09e2224596 New translations strings.xml (Filipino) 2022-11-26 22:17:51 +01:00
Eugen Rochko
5999aad21b New translations title.txt (Hungarian) 2022-11-26 22:17:50 +01:00
Eugen Rochko
874ce07c3e New translations short_description.txt (Hungarian) 2022-11-26 22:17:49 +01:00
Eugen Rochko
1787d08718 New translations full_description.txt (Hungarian) 2022-11-26 22:17:48 +01:00
Eugen Rochko
9a12be88da New translations strings.xml (Hungarian) 2022-11-26 22:17:47 +01:00
Eugen Rochko
8f6bb74e61 New translations strings.xml (Thai) 2022-11-26 21:16:11 +01:00
Grishka
e4c9eb089a Hide posts when muting, blocking or unfollowing an account 2022-11-26 23:09:46 +03:00
Grishka
0e635aec23 Allow copying the username in profile 2022-11-26 20:33:02 +03:00
Grishka
dc90c09cea Shorten interaction counters 2022-11-26 20:24:27 +03:00
Grishka
06cb335a0a Add tooltips to some icon buttons
closes #423
2022-11-26 20:21:48 +03:00
Eugen Rochko
e67bd2972a New translations strings.xml (Catalan) 2022-11-26 18:17:13 +01:00
Grishka
5a681d3557 Fix #403 2022-11-26 20:16:43 +03:00
Grishka
4200486aeb fix 2022-11-26 20:14:30 +03:00
Grishka
62411a563f Fix poll expiration
fixes #238, fixes #417
2022-11-26 20:13:46 +03:00
Grishka
2cabe94ba0 Fix #398 2022-11-26 20:02:30 +03:00
Grishka
4a6baae97a Make URLs clickable in instance rules
closes #389
2022-11-26 19:29:05 +03:00
Grishka
bb12a66781 Fix #313 2022-11-26 18:40:17 +03:00
Eugen Rochko
de5929d8d2 New translations strings.xml (Icelandic) 2022-11-26 16:28:32 +01:00
Eugen Rochko
d7699ef079 New translations strings.xml (Galician) 2022-11-26 15:29:20 +01:00
Eugen Rochko
3ab04ebca8 New translations strings.xml (Icelandic) 2022-11-26 12:55:03 +01:00
sk
aee27d36d5 remove unused script 2022-11-25 23:30:16 +01:00
sk
165255235b automatically append git hash to filename 2022-11-25 23:30:00 +01:00
Eugen Rochko
78d2aa96d7 New translations strings.xml (Belarusian) 2022-11-25 01:47:56 +01:00
sk
144fdd562f add script to get latest upstream commit 2022-11-24 20:49:44 +01:00
sk
5d6201d415 Merge remote-tracking branch 'upstream/master' into upstream-release 2022-11-24 20:47:33 +01:00
Grishka
9facdb938d Add a section about translations to readme 2022-11-22 11:48:02 +04:00
Gregory K
4d868cc5aa Merge pull request #400 from mastodon/fix-readme
Add note on contributions and trademarks to README
2022-11-22 06:59:44 +03:00
Eugen Rochko
ca9e515bd5 Merge pull request #232 from Poussinou/patch-1
Create FUNDING.yml
2022-11-22 04:34:03 +01:00
Eugen Rochko
df1d451e82 Add note on contributions and trademarks to README 2022-11-22 04:28:10 +01:00
sk
efe41f407e Merge remote-tracking branch 'upstream/master' into upstream-release 2022-11-21 23:19:25 +01:00
Grishka
2c61551e5c Add a tool to generate locales_config.xml 2022-11-21 22:04:30 +04:00
sk
e5e36f4b10 Disable minification and shrinking resources
To allow for easier debugging of issues in my fork, I intend to release
an unmodified upstream variant alongside my fork's releases. Disabling
minification and shrinking resources so the crash logs make more sense
and are easier to debug.
2022-11-21 17:11:48 +01:00
Grishka
158af27309 Fix #363 2022-11-21 18:36:33 +04:00
Grishka
187693883c Fix #94
TODO support 4.0 filteing
2022-11-21 14:10:30 +04:00
Grishka
b898dc010e Show an error if a server has signups closed
closes #377
2022-11-20 13:36:23 +04:00
Grishka
de369633ec Fix #386 2022-11-20 12:54:56 +04:00
Gregory K
3f075eab13 Merge pull request #387 from sk22/fix-screenreader-middle-dot
Omit “middle dot” for screen reader
2022-11-20 07:54:06 +03:00
sk
e99917945a omit middle dot for screen reader 2022-11-20 01:34:02 +01:00
Grishka
30151cafc2 Yes I can flip that to true 2022-11-19 23:13:50 +04:00
Jeff Bowen
5d056d5bea AboutViewHolder: Set the background of the field item to green when verified 2022-11-16 19:01:32 -05:00
Jeff Bowen
f500cc7ebf IsoInstantTypeAdapter: Enable parsing of 'offset' date times in profile response 2022-11-16 19:00:11 -05:00
Grishka
a336f6be89 Support domain redirects via /.well-known/host-meta
Closes #312
2022-11-15 15:16:58 +04:00
Grishka
79d99a9484 Probably fix #297 2022-11-15 14:11:56 +04:00
Gregory K
e4e1dc102b Merge pull request #321 from julroy67/fix-onboarding-category-cjk-cutoff
Fix CJK text cut off for category names on Onboarding screen
2022-11-15 10:13:03 +03:00
Grishka
539ac26e0d Fix #300 2022-11-14 22:59:34 +04:00
Grishka
2c11b879ae Fix #283 2022-11-13 19:44:40 +04:00
Grishka
58735094f1 Fix #319 2022-11-13 19:42:25 +04:00
Grishka
3671803f49 Fix #336 2022-11-13 19:18:42 +04:00
Gregory K
56b42071ac Merge pull request #354 from cypressious/fix-activation-button-bar
Fix button bar layout in onboarding activation for smaller widths
2022-11-13 17:48:54 +03:00
Kirill Rakhman
ba930f72e8 fix button bar layout in onboarding activation for smaller widths 2022-11-13 12:58:33 +01:00
Julien Humbert
20d838f576 Fix CJK text cut off for category names on Onboarding screen 2022-11-08 23:48:04 +09:00
Poussinou
68a9eba868 Create FUNDING.yml 2022-07-30 18:22:09 +02:00
184 changed files with 5390 additions and 1441 deletions

12
.github/FUNDING.yml vendored Normal file
View File

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

View File

@@ -1,11 +1,24 @@
# Mastodon for Android
[![Crowdin](https://badges.crowdin.net/mastodon-for-android/localized.svg)](https://crowdin.com/project/mastodon-for-android)
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>
[![Crowdin](https://badges.crowdin.net/mastodon-for-android/localized.svg)](https://crowdin.com/project/mastodon-for-android)
This is the repository for the official Android app for Mastodon.
Learn more about this app in the [blog post](https://blog.joinmastodon.org/2022/02/official-mastodon-for-android-app-is-coming-soon/).
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/org.joinmastodon.android/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=org.joinmastodon.android)
Or get the APK from the [The Releases Section](https://github.com/mastodon/mastodon-android/releases/latest).
## Contributing
Our goal is delivering a polished, professionally designed and user-friendly app. We proceed according to wireframes provided by a professional UX designer that works with Mastodon gGmbH. This means that any outside contributions that change the app visually must first be coordinated with the UX designer. *This can take time.* Furthermore, we work off of an internal roadmap and aim for feature-parity and consistency with our iOS app. The iOS app is designated as the "primary" between the two, therefore, if you want to request features, please do so in the [Mastodon for iOS](https://github.com/mastodon/mastodon-ios) repository, as you are requesting a feature to be both in iOS and Android (exceptions being system integrations specific to Android). On the other hand, any contributions that improve existing functionality, performance, or accessibility should not have any roadblocks to being merged.
If you would like to help translate the app into your language, please go to [Crowdin](https://crowdin.com/project/mastodon-for-android). If your language is not listed in the Crowdin project, please create an issue and we will add it. Please do not create pull requests that modify `strings.xml` files for languages other than English.
## Building
@@ -17,4 +30,6 @@ As this app is using Java 17 features, you need JDK 17 or newer to build it. Oth
## License
This project is released under the [GPL-3 License](./LICENSE).
This project is released under the [GPL-3 License](./LICENSE).
The Mastodon name and logo are trademarks of Mastodon gGmbH. If you intend to redistribute a modified version of this app, use a unique name and icon for your app that does not mistakenly imply any official connection with or endorsement by Mastodon gGmbH.

View File

@@ -1,16 +1,16 @@
Mastodon je největší decentralizovanou sociální sítí na internetu. Místo jednné webové stránky je to síť pro miliony uživatelů v nezávislých komunitách, kteří mohou všichni vzájemně a bezproblémově komunikovat. Bez ohledu na to, co vás baví, můžete se setkat s vášnivými lidmi, kteří o tom vysílají na Mastodon!
Mastodon je největší decentralizovanou sociální sítí na internetu. Místo jediné webové stránky je to síť pro miliony uživatelů v nezávislých komunitách, ve kterých mohou všichni vzájemně a bezproblémově komunikovat. Bez ohledu na to, co vás baví, můžete se setkat s vášnivými lidmi, kteří o tom přispívají na Mastodon!
Připojte se ke komunitě a vytvořte svůj profil. Najděte a sledujte fascinující lidi a přečtěte si jejich příspěvky v bezreklamní a chronologické časové linii. Vyjádřete se pomocí vlastních emojí, obrázků, GIFů, videí a zvuku v 500-znakových příspěvcích. Odpovězte na vlákna a reblogujte příspěvky od kohokoliv, abyste mohli sdílet skvělé věci. Najděte nové účty pro sledování a populární hashtagy pro rozšíření vaší sítě.
Připojte se ke komunitě a vytvořte svůj profil. Najděte a sledujte fascinující lidi a přečtěte si jejich příspěvky v chronologické časové ose bez reklam. Vyjádřete se pomocí vlastních emoji, obrázků, GIFů, videí a zvuku v 500-znakových příspěvcích. Odpovězte na vlákna a boostujte příspěvky od kohokoliv, abyste mohli sdílet skvělé věci. Najděte nové účty pro sledování a populární hashtagy pro rozšíření vaší sítě.
Mastodon je postaven se zaměřením na soukromí a bezpečnost. Rozhodněte, zda jsou vaše příspěvky sdíleny se svými sledujícími, jen s lidmi, které zmiňujete, nebo s celým světem. Upozornění na obsah vám umožní skrýt příspěvky obsahující citlivý nebo spouštěcí materiál, dokud se s nimi nezačnete zabývat. Každá komunita má vlastní pokyny a moderátory, aby udržela své členy v bezpečí, a robustní blokování a hlášení nástrojů pomáhá předcházet zneužití.
Mastodon je postaven se zaměřením na soukromí a bezpečnost. Rozhodněte, zda jsou vaše příspěvky sdíleny se vašimi sledujícími, jen s lidmi, které zmíníte, nebo s celým světem. Upozornění na obsah vám umožní skrýt příspěvky obsahující citlivý nebo spouštěcí materiál, dokud se s nimi nezačnete zabývat. Každá komunita má vlastní pokyny a moderátory, aby udržela své členy v bezpečí, a robustní blokování a nahlašovací nástroje pomáhá předcháze zneužití.
Další funkce:
Více funkcí:
• Tmavý režim: Čtěte příspěvky ve světlém, tmavém nebo zcela černém režimu
• Ankety: Požádejte sledující o jejich názor a spojte se s jejich hlasováním
Průzkum: Trendové hashtagy a účty jsou pryč na jedno klepnutí
Upozornění: Dostávejte upozorněna nové sledování, odpovědi a reblogy
• Tmavý režim: Čtěte příspěvky ve světlém, tmavém nebo pravém černém režimu
• Ankety: Požádejte sledující o jejich názor a sečtěte jejich hlasy
Objevit: Populární hashtagy a účty jsou pryč na jedno klepnutí
Oznámení: Dostávejte oznámeo nových sledujících, odpovědích a boostech
• Sdílení: Odesílání přímo do Mastodonu z libovolného seznamu sdílení v jakékoliv aplikaci
• Roztomilost: Naším maskotem je roztomilý slon, kterého čas od času uvidíte
Mastodon je registrovaný neziskový projekt a vývojový program je podporován přímo vašimi dary. Neexistuje žádná reklama, žádná monetizace a žádný rizikový kapitál a my máme v plánu to udržet.
Mastodon je registrovaný neziskový projekt a vývojový program je podporován přímo vašimi dary. Neexistuje žádná reklama, žádná monetizace a žádný rizikový kapitál a máme v plánu to udržet.

View File

@@ -1,8 +1,8 @@
Mastodon ist das größte dezentralisierte soziale Netzwerk im Internet. Statt einer einzigen Webseite ist es ein Netzwerk von Millionen von Benutzer*innen in unabhängigen Gemeinschaften, die alle miteinander interagieren können. Egal, was du magst, auf Mastodon kannst du begeisterte Menschen treffen, die darüber schreiben!
Tritt einer Gemeinschaft bei und erstelle dein Profil. Finde und folge faszinierenden Leuten und lies ihre Beiträge in einer werbefreien, chronologischen Zeitachse. Drücke dich mit eigenen Emojis, Bildern, GIFs, Videos und Klängen in 500-Zeichen-Beiträgen aus. Antworte auf Threads und teile Beiträge von anderen, um großartige Sachen zu verbreiten. Finde neue Accounts zum Folgen und angesagte Hashtags, um dein Netzwerk zu erweitern.
Tritt einer Gemeinschaft bei und erstelle dein Profil. Finde und folge faszinierenden Leuten und lies ihre Beiträge in einer werbefreien, chronologischen Zeitachse. Drücke dich mit eigenen Emojis, Bildern, GIFs, Videos und Klängen in 500-Zeichen-Beiträgen aus. Antworte auf Themen und teile Beiträge von anderen, um tolle Dinge zu verbreiten. Finde neue Konten zum Folgen und angesagte Hashtags, um dein Netzwerk zu erweitern.
Mastodon wurde mit einem Schwerpunkt auf Privatsphäre und Sicherheit gebaut. Entscheide, ob du deine Beiträge mit deinen Followern, nur mit den Menschen, die du erwähnst, oder mit der ganzen Welt teilen möchtest. Mit Inhaltswarnungen kannst du Beiträge mit sensiblem oder triggerndem Inhalt ausblenden, bis du bereit bist, dich damit auseinanderzusetzen. Jede Gemeinschaft hat ihre eigenen Regeln und Moderator*innen, um die Sicherheit ihrer Mitglieder zu gewährleisten, sowie robuste Sperr- und Meldewerkzeuge, um Missbrauch vorzubeugen.
Mastodon wurde mit einem Schwerpunkt auf Privatsphäre und Sicherheit gebaut. Entscheide, ob du deine Beiträge mit deinen Followern, nur mit den Menschen, die du erwähnst, oder mit der ganzen Welt teilen möchtest. Mit Inhaltswarnungen kannst du Beiträge mit sensiblem oder bedenklichen Inhalten ausblenden, bis du bereit bist, dich damit auseinanderzusetzen. Jede Gemeinschaft hat ihre eigenen Regeln und Moderator*innen, um die Sicherheit ihrer Mitglieder zu gewährleisten, sowie robuste Sperr- und Meldewerkzeuge, um Missbrauch vorzubeugen.
Weitere Funktionen:

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

@@ -0,0 +1,16 @@
A Mastodon a legnagyobb decentralizált közösségi hálózat az interneten. Egyetlen weboldal helyett, ez több millió felhasználóból álló, független közösségek hálózata, amelyek egymással kapcsolatba tudnak lépni, zökkenőmentesen. Nem számít, mi a hobbid, a Mastodonon találkozhatsz róla posztoló lelkes emberekkel!
Csatlakozz egy közösséghez és készítsd el a profilodat. Keress és kövess lenyűgöző embereket, és olvasd egy reklámmentes, kronologikus idővonalon a bejegyzéseiket. Fejezd ki magad egyedi hangulatjelekkel, képekkel, GIFekkel, videókkal és hanggal, 500 karakter hosszúságú posztokban. Reply to threads and reblog posts from anyone to share great stuff. Fedezz fel új fiókokat amiket követhetsz és felkapott hashtageket, hogy bővíthesd a kapcsolataidat.
A Mastodon az adatvédelemre és a biztonságra összpontosítva épült. Döntsd el, hogy a posztjaidat csak a követőiddel, csak azokkal akiket megemlítesz, vagy az egész világgal osztod meg. 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 @@
Decentralizált szociális hálózat

View File

@@ -0,0 +1 @@
Mastodon

View File

@@ -4,24 +4,31 @@ plugins {
}
android {
def getGitHash = { ->
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-parse', '--short', 'upstream/master'
standardOutput = stdout
}
return stdout.toString().trim()
}
compileSdk 33
defaultConfig {
applicationId "org.joinmastodon.android"
archivesBaseName = "upstream-${getGitHash()}"
applicationId "org.joinmastodon.android.git"
minSdk 23
targetSdk 33
versionCode 43
versionName "1.1.4"
versionCode 47
versionName "1.1.5+${getGitHash()}"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs "en", "ar-rSA", "bs-rBA", "ca-rES", "cs-rCZ", "de-rDE", "el-rGR", "es-rES",
"eu-rES", "fi-rFI", "fr-rFR", "gl-rES", "hr-rHR", "hy-rAM", "it-rIT", "iw-rIL",
"ja-rJP", "kab", "ko-rKR", "oc-rFR", "pl-rPL", "pt-rBR", "pt-rPT", "ru-rRU",
"sv-rSE", "th-rTH", "tr-rTR", "uk-rUA", "vi-rVN", "zh-rCN", "zh-rTW"
resConfigs "ar-rSA", "be-rBY", "bn-rBD", "bs-rBA", "ca-rES", "cs-rCZ", "de-rDE", "el-rGR", "es-rES", "eu-rES", "fi-rFI", "fil-rPH", "fr-rFR", "ga-rIE", "gd-rGB", "gl-rES", "hi-rIN", "hr-rHR", "hu-rHU", "hy-rAM", "in-rID", "is-rIS", "it-rIT", "iw-rIL", "ja-rJP", "kab", "ko-rKR", "nl-rNL", "oc-rFR", "pl-rPL", "pt-rBR", "pt-rPT", "ro-rRO", "ru-rRU", "si-rLK", "sl-rSI", "sv-rSE", "th-rTH", "tr-rTR", "uk-rUA", "vi-rVN", "zh-rCN", "zh-rTW"
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
// minifyEnabled true
// shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug{
@@ -72,7 +79,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.6'
implementation 'me.grishka.appkit:appkit:1.2.7'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.14.3'
implementation 'com.squareup:otto:1.3.8'
@@ -91,4 +98,4 @@ dependencies {
androidTestImplementation 'androidx.test.ext:junit:1.1.4-alpha05'
androidTestImplementation 'androidx.test:runner:1.5.0-alpha02'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0-alpha05'
}
}

View File

@@ -44,6 +44,10 @@
*;
}
-keep interface org.parceler.Parcel
-keep @org.parceler.Parcel class * { *; }
-keep class **$$Parcelable { *; }
-keep class org.joinmastodon.android.AppCenterWrapper { *; }
-keepattributes LineNumberTable

View File

@@ -64,7 +64,7 @@ public class OAuthActivity extends Activity{
.setCallback(new Callback<>(){
@Override
public void onSuccess(Account account){
AccountSessionManager.getInstance().addAccount(instance, token, account, app, true);
AccountSessionManager.getInstance().addAccount(instance, token, account, app, null);
progress.dismiss();
finish();
// not calling restartMainActivity() here on purpose to have it recreated (notice different flags)

View File

@@ -73,7 +73,7 @@ public class CacheController{
status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0);
newMaxID=status.id;
for(Filter filter:filters){
if(filter.matches(status.getContentStatus().content))
if(filter.matches(status))
continue outer;
}
result.add(status);
@@ -145,7 +145,7 @@ public class CacheController{
newMaxID=ntf.id;
if(ntf.status!=null){
for(Filter filter:filters){
if(filter.matches(ntf.status.getContentStatus().content))
if(filter.matches(ntf.status))
continue outer;
}
}
@@ -166,7 +166,7 @@ public class CacheController{
callback.onSuccess(new PaginatedResponse<>(result.stream().filter(ntf->{
if(ntf.status!=null){
for(Filter filter:filters){
if(filter.matches(ntf.status.getContentStatus().content)){
if(filter.matches(ntf.status)){
return false;
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.api;
import com.google.gson.JsonElement;
import com.google.gson.JsonIOException;
import java.io.IOException;
@@ -26,7 +27,10 @@ public class JsonObjectRequestBody extends RequestBody{
public void writeTo(BufferedSink sink) throws IOException{
try{
OutputStreamWriter writer=new OutputStreamWriter(sink.outputStream(), StandardCharsets.UTF_8);
MastodonAPIController.gson.toJson(obj, writer);
if(obj instanceof JsonElement)
writer.write(obj.toString());
else
MastodonAPIController.gson.toJson(obj, writer);
writer.flush();
}catch(JsonIOException x){
throw new IOException(x);

View File

@@ -191,15 +191,18 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
}
void onError(ErrorResponse err){
invokeErrorCallback(err);
if(!canceled)
invokeErrorCallback(err);
}
void onError(String msg, int httpStatus, Throwable exception){
invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus, exception));
if(!canceled)
invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus, exception));
}
void onSuccess(T resp){
invokeSuccessCallback(resp);
if(!canceled)
invokeSuccessCallback(resp);
}
@Override

View File

@@ -162,6 +162,8 @@ public class PushSubscriptionManager{
@Override
public void onSuccess(PushSubscription result){
MastodonAPIController.runInBackground(()->{
result.serverKey=result.serverKey.replace('/','_');
result.serverKey=result.serverKey.replace('+','-');
serverKey=deserializeRawPublicKey(Base64.decode(result.serverKey, Base64.URL_SAFE));
AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID);
@@ -365,6 +367,8 @@ public class PushSubscriptionManager{
}
private static void registerAllAccountsForPush(boolean forceReRegister){
if(!arePushNotificationsAvailable())
return;
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
if(session.pushSubscription==null || forceReRegister)
session.getPushSubscriptionManager().registerAccountForPush(session.pushSubscription);

View File

@@ -4,6 +4,7 @@ import android.os.Looper;
import org.joinmastodon.android.E;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked;
import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited;
import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
@@ -18,6 +19,7 @@ public class StatusInteractionController{
private final String accountID;
private final HashMap<String, SetStatusFavorited> runningFavoriteRequests=new HashMap<>();
private final HashMap<String, SetStatusReblogged> runningReblogRequests=new HashMap<>();
private final HashMap<String, SetStatusBookmarked> runningBookmarkRequests=new HashMap<>();
public StatusInteractionController(String accountID){
this.accountID=accountID;
@@ -98,4 +100,34 @@ public class StatusInteractionController{
status.reblogsCount--;
E.post(new StatusCountersUpdatedEvent(status));
}
public void setBookmarked(Status status, boolean bookmarked){
if(!Looper.getMainLooper().isCurrentThread())
throw new IllegalStateException("Can only be called from main thread");
SetStatusBookmarked current=runningBookmarkRequests.remove(status.id);
if(current!=null){
current.cancel();
}
SetStatusBookmarked req=(SetStatusBookmarked) new SetStatusBookmarked(status.id, bookmarked)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
runningBookmarkRequests.remove(status.id);
E.post(new StatusCountersUpdatedEvent(result));
}
@Override
public void onError(ErrorResponse error){
runningBookmarkRequests.remove(status.id);
error.showToast(MastodonApp.context);
status.bookmarked=!bookmarked;
E.post(new StatusCountersUpdatedEvent(status));
}
})
.exec(accountID);
runningBookmarkRequests.put(status.id, req);
status.bookmarked=bookmarked;
E.post(new StatusCountersUpdatedEvent(status));
}
}

View File

@@ -25,10 +25,21 @@ public class IsoInstantTypeAdapter extends TypeAdapter<Instant>{
in.nextNull();
return null;
}
try{
return DateTimeFormatter.ISO_INSTANT.parse(in.nextString(), Instant::from);
}catch(DateTimeParseException x){
String nextString;
try {
nextString = in.nextString();
}catch(Exception e){
return null;
}
try{
return DateTimeFormatter.ISO_INSTANT.parse(nextString, Instant::from);
}catch(DateTimeParseException x){}
try{
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(nextString, Instant::from);
}catch(DateTimeParseException x){}
return null;
}
}

View File

@@ -0,0 +1,42 @@
package org.joinmastodon.android.api.gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
public class JsonArrayBuilder{
private JsonArray arr=new JsonArray();
public JsonArrayBuilder add(JsonElement el){
arr.add(el);
return this;
}
public JsonArrayBuilder add(String el){
arr.add(el);
return this;
}
public JsonArrayBuilder add(Number el){
arr.add(el);
return this;
}
public JsonArrayBuilder add(boolean el){
arr.add(el);
return this;
}
public JsonArrayBuilder add(JsonObjectBuilder el){
arr.add(el.build());
return this;
}
public JsonArrayBuilder add(JsonArrayBuilder el){
arr.add(el.build());
return this;
}
public JsonArray build(){
return arr;
}
}

View File

@@ -0,0 +1,42 @@
package org.joinmastodon.android.api.gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
public class JsonObjectBuilder{
private JsonObject obj=new JsonObject();
public JsonObjectBuilder add(String key, JsonElement el){
obj.add(key, el);
return this;
}
public JsonObjectBuilder add(String key, String el){
obj.addProperty(key, el);
return this;
}
public JsonObjectBuilder add(String key, Number el){
obj.addProperty(key, el);
return this;
}
public JsonObjectBuilder add(String key, boolean el){
obj.addProperty(key, el);
return this;
}
public JsonObjectBuilder add(String key, JsonObjectBuilder el){
obj.add(key, el.build());
return this;
}
public JsonObjectBuilder add(String key, JsonArrayBuilder el){
obj.add(key, el.build());
return this;
}
public JsonObject build(){
return obj;
}
}

View File

@@ -0,0 +1,21 @@
package org.joinmastodon.android.api.requests.markers;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.gson.JsonObjectBuilder;
import org.joinmastodon.android.model.Marker;
public class SaveMarkers extends MastodonAPIRequest<SaveMarkers.Response>{
public SaveMarkers(String lastSeenHomePostID, String lastSeenNotificationID){
super(HttpMethod.POST, "/markers", Response.class);
JsonObjectBuilder builder=new JsonObjectBuilder();
if(lastSeenHomePostID!=null)
builder.add("home", new JsonObjectBuilder().add("last_read_id", lastSeenHomePostID));
if(lastSeenNotificationID!=null)
builder.add("notifications", new JsonObjectBuilder().add("last_read_id", lastSeenNotificationID));
setRequestBody(builder.build());
}
public static class Response{
public Marker home, notifications;
}
}

View File

@@ -9,7 +9,7 @@ public class RegisterForPushNotifications extends MastodonAPIRequest<PushSubscri
Request r=new Request();
r.subscription.endpoint="https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"+accountID;
r.data.alerts=alerts;
r.data.policy=policy;
r.policy=policy;
r.subscription.keys.p256dh=encryptionKey;
r.subscription.keys.auth=authKey;
setRequestBody(r);
@@ -18,6 +18,7 @@ public class RegisterForPushNotifications extends MastodonAPIRequest<PushSubscri
private static class Request{
public Subscription subscription=new Subscription();
public Data data=new Data();
public PushSubscription.Policy policy;
private static class Keys{
public String p256dh;
@@ -31,7 +32,6 @@ public class RegisterForPushNotifications extends MastodonAPIRequest<PushSubscri
private static class Data{
public PushSubscription.Alerts alerts;
public PushSubscription.Policy policy;
}
}
}

View File

@@ -3,23 +3,36 @@ package org.joinmastodon.android.api.requests.notifications;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.PushSubscription;
import java.io.IOException;
import okhttp3.Response;
public class UpdatePushSettings extends MastodonAPIRequest<PushSubscription>{
private final PushSubscription.Policy policy;
public UpdatePushSettings(PushSubscription.Alerts alerts, PushSubscription.Policy policy){
super(HttpMethod.PUT, "/push/subscription", PushSubscription.class);
setRequestBody(new Request(alerts, policy));
this.policy=policy;
}
@Override
public void validateAndPostprocessResponse(PushSubscription respObj, Response httpResponse) throws IOException{
super.validateAndPostprocessResponse(respObj, httpResponse);
respObj.policy=policy;
}
private static class Request{
public Data data=new Data();
public PushSubscription.Policy policy;
public Request(PushSubscription.Alerts alerts, PushSubscription.Policy policy){
this.data.alerts=alerts;
this.data.policy=policy;
this.policy=policy;
}
private static class Data{
public PushSubscription.Alerts alerts;
public PushSubscription.Policy policy;
}
}
}

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.Status;
public class GetBookmarkedStatuses extends HeaderPaginationRequest<Status>{
public GetBookmarkedStatuses(String maxID, int limit){
super(HttpMethod.GET, "/bookmarks", 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.Status;
public class GetFavoritedStatuses extends HeaderPaginationRequest<Status>{
public GetFavoritedStatuses(String maxID, int limit){
super(HttpMethod.GET, "/favourites", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
public class SetStatusBookmarked extends MastodonAPIRequest<Status>{
public SetStatusBookmarked(String id, boolean bookmarked){
super(HttpMethod.POST, "/statuses/"+id+"/"+(bookmarked ? "bookmark" : "unbookmark"), Status.class);
setRequestBody(new Object());
}
}

View File

@@ -8,9 +8,11 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetTrendingStatuses extends MastodonAPIRequest<List<Status>>{
public GetTrendingStatuses(int limit){
public GetTrendingStatuses(int offset, int limit){
super(HttpMethod.GET, "/trends/statuses", new TypeToken<>(){});
if(limit>0)
addQueryParameter("limit", ""+limit);
if(offset>0)
addQueryParameter("offset", ""+offset);
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.session;
public class AccountActivationInfo{
public String email;
public long lastEmailConfirmationResend;
public AccountActivationInfo(String email, long lastEmailConfirmationResend){
this.email=email;
this.lastEmailConfirmationResend=lastEmailConfirmationResend;
}
}

View File

@@ -28,17 +28,19 @@ public class AccountSession{
public long filtersLastUpdated;
public List<Filter> wordFilters=new ArrayList<>();
public String pushAccountID;
public AccountActivationInfo activationInfo;
private transient MastodonAPIController apiController;
private transient StatusInteractionController statusInteractionController;
private transient CacheController cacheController;
private transient PushSubscriptionManager pushSubscriptionManager;
AccountSession(Token token, Account self, Application app, String domain, boolean activated){
AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){
this.token=token;
this.self=self;
this.domain=domain;
this.app=app;
this.activated=activated;
this.activationInfo=activationInfo;
infoLastUpdated=System.currentTimeMillis();
}

View File

@@ -100,9 +100,9 @@ public class AccountSessionManager{
maybeUpdateShortcuts();
}
public void addAccount(Instance instance, Token token, Account self, Application app, boolean active){
public void addAccount(Instance instance, Token token, Account self, Application app, AccountActivationInfo activationInfo){
instances.put(instance.uri, instance);
AccountSession session=new AccountSession(token, self, app, instance.uri, active);
AccountSession session=new AccountSession(token, self, app, instance.uri, activationInfo==null, activationInfo);
sessions.put(session.getID(), session);
lastActiveAccountID=session.getID();
writeAccountsFile();

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.events;
public class RemoveAccountPostsEvent{
public final String accountID;
public final String postsByAccountID;
public final boolean isUnfollow;
public RemoveAccountPostsEvent(String accountID, String postsByAccountID, boolean isUnfollow){
this.accountID=accountID;
this.postsByAccountID=postsByAccountID;
this.isUnfollow=isUnfollow;
}
}

View File

@@ -5,7 +5,7 @@ import org.joinmastodon.android.model.Status;
public class StatusCountersUpdatedEvent{
public String id;
public long favorites, reblogs, replies;
public boolean favorited, reblogged;
public boolean favorited, reblogged, bookmarked;
public StatusCountersUpdatedEvent(Status s){
id=s.id;
@@ -14,5 +14,6 @@ public class StatusCountersUpdatedEvent{
replies=s.repliesCount;
favorited=s.favourited;
reblogged=s.reblogged;
bookmarked=s.bookmarked;
}
}

View File

@@ -3,9 +3,11 @@ package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Status;
public class StatusCreatedEvent{
public Status status;
public final Status status;
public final String accountID;
public StatusCreatedEvent(Status status){
public StatusCreatedEvent(Status status, String accountID){
this.status=status;
this.accountID=accountID;
}
}

View File

@@ -7,6 +7,7 @@ import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Status;
@@ -86,4 +87,9 @@ public class AccountTimelineFragment extends StatusListFragment{
}
prependItems(Collections.singletonList(ev.status), true);
}
@Override
protected void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){
// no-op
}
}

View File

@@ -439,7 +439,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, false)
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}

View File

@@ -0,0 +1,37 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetBookmarkedStatuses;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Status;
import me.grishka.appkit.api.SimpleCallback;
public class BookmarkedStatusListFragment extends StatusListFragment{
private String nextMaxID;
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.bookmarks);
loadData();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetBookmarkedStatuses(offset==0 ? null : nextMaxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Status> result){
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result, nextMaxID!=null);
}
})
.exec(accountID);
}
}

View File

@@ -1,5 +1,7 @@
package org.joinmastodon.android.fragments;
import static android.os.ext.SdkExtensions.getExtensionVersion;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.Activity;
@@ -20,6 +22,7 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcelable;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.text.Editable;
import android.text.InputFilter;
@@ -27,7 +30,6 @@ import android.text.Layout;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.format.DateUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
@@ -51,7 +53,6 @@ import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import com.twitter.twittertext.Regex;
import com.twitter.twittertext.TwitterTextEmojiRegex;
import org.joinmastodon.android.E;
@@ -101,7 +102,6 @@ import org.parceler.Parcels;
import java.io.InterruptedIOException;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
@@ -132,21 +132,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private static final Pattern AUTO_COMPLETE_PATTERN=Pattern.compile("(?<!\\w)(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+)|:([a-zA-Z0-9_]+))");
private static final Pattern HIGHLIGHT_PATTERN=Pattern.compile("(?<!\\w)(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))");
private static final String VALID_URL_PATTERN_STRING =
"(" + // $1 total match
"(" + Regex.URL_VALID_PRECEDING_CHARS + ")" + // $2 Preceding character
"(" + // $3 URL
"(https?://)" + // $4 Protocol (optional)
"(" + Regex.URL_VALID_DOMAIN + ")" + // $5 Domain(s)
"(?::(" + Regex.URL_VALID_PORT_NUMBER + "))?" + // $6 Port number (optional)
"(/" +
Regex.URL_VALID_PATH + "*+" +
")?" + // $7 URL Path and anchor
"(\\?" + Regex.URL_VALID_URL_QUERY_CHARS + "*" + // $8 Query String
Regex.URL_VALID_URL_QUERY_ENDING_CHARS + ")?" +
")" +
")";
private static final Pattern URL_PATTERN=Pattern.compile(VALID_URL_PATTERN_STRING, Pattern.CASE_INSENSITIVE);
@SuppressLint("NewApi") // this class actually exists on 6.0
private final BreakIterator breakIterator=BreakIterator.getCharacterInstance();
@@ -229,7 +214,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
else
charLimit=500;
loadDefaultStatusVisibility(savedInstanceState);
if (editingStatus == null) loadDefaultStatusVisibility(savedInstanceState);
}
@Override
@@ -428,6 +413,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
mainEditText.setSelectionListener(this);
mainEditText.addTextChangedListener(new TextWatcher(){
private int lastChangeStart, lastChangeCount;
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){
@@ -437,6 +424,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public void onTextChanged(CharSequence s, int start, int before, int count){
if(s.length()==0)
return;
lastChangeStart=start;
lastChangeCount=count;
}
@Override
public void afterTextChanged(Editable s){
if(s.length()==0)
return;
int start=lastChangeStart;
int count=lastChangeCount;
// offset one char back to catch an already typed '@' or '#' or ':'
int realStart=start;
start=Math.max(0, start-1);
@@ -482,10 +479,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
editable.removeSpan(span);
}
}
}
@Override
public void afterTextChanged(Editable s){
updateCharCounter();
}
});
@@ -532,7 +526,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
DraftMediaAttachment da=new DraftMediaAttachment();
da.serverAttachment=att;
da.description=att.description;
da.uri=Uri.parse(att.previewUrl);
da.uri=att.previewUrl!=null ? Uri.parse(att.previewUrl) : null;
da.state=AttachmentUploadState.DONE;
attachmentsView.addView(createMediaAttachmentView(da));
attachments.add(da);
@@ -612,7 +606,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
String countableText=TwitterTextEmojiRegex.VALID_EMOJI_PATTERN.matcher(
MENTION_PATTERN.matcher(
URL_PATTERN.matcher(text).replaceAll("$2xxxxxxxxxxxxxxxxxxxxxxx")
HtmlParser.URL_PATTERN.matcher(text).replaceAll("$2xxxxxxxxxxxxxxxxxxxxxxx")
).replaceAll("$1@$3")
).replaceAll("x");
charCount=0;
@@ -705,7 +699,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
wm.removeView(sendingOverlay);
sendingOverlay=null;
if(editingStatus==null){
E.post(new StatusCreatedEvent(result));
E.post(new StatusCreatedEvent(result, accountID));
if(replyTo!=null){
replyTo.repliesCount++;
E.post(new StatusCountersUpdatedEvent(replyTo));
@@ -800,14 +794,50 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
.show();
}
/**
* Check to see if Android platform photopicker is available on the device\
* @return whether the device supports photopicker intents.
*/
private boolean isPhotoPickerAvailable() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return true;
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return getExtensionVersion(Build.VERSION_CODES.R) >= 2;
} else
return false;
}
/**
* Builds the correct intent for the device version to select media.
*
* <p>For Device version > T or R_SDK_v2, use the android platform photopicker via
* {@link MediaStore#ACTION_PICK_IMAGES}
*
* <p>For earlier versions use the built in docs ui via {@link Intent#ACTION_GET_CONTENT}
*/
private void openFilePicker(){
Intent intent=new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
if(instance.configuration!=null && instance.configuration.mediaAttachments!=null && instance.configuration.mediaAttachments.supportedMimeTypes!=null && !instance.configuration.mediaAttachments.supportedMimeTypes.isEmpty()){
intent.putExtra(Intent.EXTRA_MIME_TYPES, instance.configuration.mediaAttachments.supportedMimeTypes.toArray(new String[0]));
}else{
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*", "video/*"});
Intent intent;
boolean usePhotoPicker = isPhotoPickerAvailable();
if (usePhotoPicker) {
intent = new Intent(MediaStore.ACTION_PICK_IMAGES);
intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MediaStore.getPickImagesMaxLimit());
} else {
intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
}
if (!usePhotoPicker && instance.configuration != null &&
instance.configuration.mediaAttachments != null &&
instance.configuration.mediaAttachments.supportedMimeTypes != null &&
!instance.configuration.mediaAttachments.supportedMimeTypes.isEmpty()) {
intent.putExtra(Intent.EXTRA_MIME_TYPES,
instance.configuration.mediaAttachments.supportedMimeTypes.toArray(
new String[0]));
} else {
if (!usePhotoPicker) {
// If photo picker is being used these are the default mimetypes.
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*", "video/*"});
}
}
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
startActivityForResult(intent, MEDIA_RESULT);
@@ -889,7 +919,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false);
ImageView img=thumb.findViewById(R.id.thumb);
if(draft.serverAttachment!=null){
ViewImageLoader.load(img, draft.serverAttachment.blurhashPlaceholder, new UrlImageLoaderRequest(draft.serverAttachment.previewUrl, V.dp(250), V.dp(250)));
if(draft.serverAttachment.previewUrl!=null)
ViewImageLoader.load(img, draft.serverAttachment.blurhashPlaceholder, new UrlImageLoaderRequest(draft.serverAttachment.previewUrl, V.dp(250), V.dp(250)));
}else{
if(draft.mimeType.startsWith("image/")){
ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250)));

View File

@@ -0,0 +1,37 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetFavoritedStatuses;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Status;
import me.grishka.appkit.api.SimpleCallback;
public class FavoritedStatusListFragment extends StatusListFragment{
private String nextMaxID;
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.your_favorites);
loadData();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetFavoritedStatuses(offset==0 ? null : nextMaxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Status> result){
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result, nextMaxID!=null);
}
})
.exec(accountID);
}
}

View File

@@ -25,6 +25,7 @@ import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
@@ -61,6 +62,7 @@ public class HomeTimelineFragment extends StatusListFragment{
private AnimatorSet currentNewPostsAnim;
private String maxID;
private String lastSavedMarkerID;
public HomeTimelineFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
@@ -142,7 +144,29 @@ public class HomeTimelineFragment extends StatusListFragment{
}
}
@Subscribe
@Override
protected void onHidden(){
super.onHidden();
if(!data.isEmpty()){
String topPostID=displayItems.get(list.getChildAdapterPosition(list.getChildAt(0))-getMainAdapterOffset()).parentID;
if(!topPostID.equals(lastSavedMarkerID)){
lastSavedMarkerID=topPostID;
new SaveMarkers(topPostID, null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(SaveMarkers.Response result){
}
@Override
public void onError(ErrorResponse error){
lastSavedMarkerID=null;
}
})
.exec(accountID);
}
}
}
public void onStatusCreated(StatusCreatedEvent ev){
prependItems(Collections.singletonList(ev.status), true);
}
@@ -258,7 +282,7 @@ public class HomeTimelineFragment extends StatusListFragment{
if(idsBelowGap.contains(s.id))
break;
for(Filter filter:filters){
if(filter.matches(s.getContentStatus().content)){
if(filter.matches(s)){
continue outer;
}
}
@@ -423,4 +447,9 @@ public class HomeTimelineFragment extends StatusListFragment{
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
updateUpdateState(ev.state);
}
@Override
protected boolean shouldRemoveAccountPostsWhenUnfollowing(){
return true;
}
}

View File

@@ -8,8 +8,10 @@ import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.Status;
@@ -26,6 +28,7 @@ import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
@@ -111,6 +114,10 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
.collect(Collectors.toSet());
loadRelationships(needRelationships);
maxID=result.maxID;
if(offset==0 && !result.items.isEmpty()){
new SaveMarkers(null, result.items.get(0).id).exec(accountID);
}
}
});
}
@@ -180,4 +187,36 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
}
}
@Subscribe
public void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){
if(!ev.accountID.equals(accountID) || ev.isUnfollow)
return;
List<Notification> toRemove=Stream.concat(data.stream(), preloadedData.stream())
.filter(n->n.account!=null && n.account.id.equals(ev.postsByAccountID))
.collect(Collectors.toList());
for(Notification n:toRemove){
removeNotification(n);
}
}
private void removeNotification(Notification n){
data.remove(n);
preloadedData.remove(n);
int index=-1;
for(int i=0;i<displayItems.size();i++){
if(n.id.equals(displayItems.get(i).parentID)){
index=i;
break;
}
}
if(index==-1)
return;
int lastIndex;
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(n.id))
break;
}
displayItems.subList(index, lastIndex).clear();
adapter.notifyItemRangeRemoved(index, lastIndex-index);
}
}

View File

@@ -183,7 +183,7 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF
}
private abstract class BaseViewHolder extends BindableViewHolder<AccountField>{
private ShapeDrawable background=new ShapeDrawable();
protected ShapeDrawable background=new ShapeDrawable();
public BaseViewHolder(int layout){
super(getActivity(), layout, list);
@@ -220,6 +220,20 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF
super.onBind(item);
title.setText(item.parsedName);
value.setText(item.parsedValue);
if(item.verifiedAt!=null){
background.getPaint().setColor(UiUtils.isDarkTheme() ? 0xFF49595a : 0xFFd7e3da);
int textColor=UiUtils.isDarkTheme() ? 0xFF89bb9c : 0xFF5b8e63;
value.setTextColor(textColor);
value.setLinkTextColor(textColor);
Drawable check=getResources().getDrawable(R.drawable.ic_fluent_checkmark_24_regular, getActivity().getTheme()).mutate();
check.setTint(textColor);
value.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, check, null);
}else{
background.getPaint().setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight));
value.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary));
value.setLinkTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.colorAccent));
value.setCompoundDrawables(null, null, null, null);
}
}
@Override

View File

@@ -6,6 +6,8 @@ import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.app.Fragment;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Outline;
@@ -34,6 +36,7 @@ import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.Toolbar;
import org.joinmastodon.android.GlobalUserPreferences;
@@ -272,6 +275,18 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
followersBtn.setOnClickListener(this::onFollowersOrFollowingClick);
followingBtn.setOnClickListener(this::onFollowersOrFollowingClick);
username.setOnLongClickListener(v->{
String username=account.acct;
if(!username.contains("@")){
username+="@"+AccountSessionManager.getInstance().getAccount(accountID).domain;
}
getActivity().getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, "@"+username));
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.TIRAMISU || UiUtils.isMIUI()){ // Android 13+ SystemUI shows its own thing when you put things into the clipboard
Toast.makeText(getActivity(), R.string.text_copied, Toast.LENGTH_SHORT).show();
}
return true;
});
return sizeWrapper;
}
@@ -519,15 +534,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
}
if(relationship==null && !isOwnProfile)
return;
inflater.inflate(R.menu.profile, menu);
inflater.inflate(isOwnProfile ? R.menu.profile_own : R.menu.profile, menu);
menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getDisplayUsername()));
if(isOwnProfile){
for(int i=0;i<menu.size();i++){
MenuItem item=menu.getItem(i);
item.setVisible(item.getItemId()==R.id.share);
}
if(isOwnProfile)
return;
}
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()));
@@ -580,6 +591,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
})
.wrapProgress(getActivity(), R.string.loading, false)
.exec(accountID);
}else if(id==R.id.bookmarks){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), BookmarkedStatusListFragment.class, args);
}else if(id==R.id.favorites){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), FavoritedStatusListFragment.class, args);
}
return true;
}

View File

@@ -35,6 +35,7 @@ import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.PushSubscriptionManager;
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
@@ -166,7 +167,7 @@ public class SettingsFragment extends MastodonToolbarFragment{
@Override
public void onDestroy(){
super.onDestroy();
if(needUpdateNotificationSettings){
if(needUpdateNotificationSettings && PushSubscriptionManager.arePushNotificationsAvailable()){
AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription);
}
}

View File

@@ -1,20 +1,28 @@
package org.joinmastodon.android.fragments;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.text.SpannableString;
import android.text.style.ReplacementSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowInsets;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.onboarding.InstanceCatalogFragment;
import org.joinmastodon.android.ui.InterpolatingMotionEffect;
import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment;
import org.joinmastodon.android.fragments.onboarding.InstanceChooserLoginFragment;
import org.joinmastodon.android.ui.views.SizeListenerFrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import me.grishka.appkit.Nav;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.utils.V;
@@ -23,12 +31,13 @@ public class SplashFragment extends AppKitFragment{
private SizeListenerFrameLayout contentView;
private View artContainer, blueFill, greenFill;
private InterpolatingMotionEffect motionEffect;
private ViewPager2 pager;
private ViewGroup pagerDots;
private View artClouds, artPlaneElephant, artRightHill, artLeftHill, artCenterHill;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
motionEffect=new InterpolatingMotionEffect(MastodonApp.context);
}
@Nullable
@@ -37,15 +46,44 @@ public class SplashFragment extends AppKitFragment{
contentView=(SizeListenerFrameLayout) inflater.inflate(R.layout.fragment_splash, container, false);
contentView.findViewById(R.id.btn_get_started).setOnClickListener(this::onButtonClick);
contentView.findViewById(R.id.btn_log_in).setOnClickListener(this::onButtonClick);
artClouds=contentView.findViewById(R.id.art_clouds);
artPlaneElephant=contentView.findViewById(R.id.art_plane_elephant);
artRightHill=contentView.findViewById(R.id.art_right_hill);
artLeftHill=contentView.findViewById(R.id.art_left_hill);
artCenterHill=contentView.findViewById(R.id.art_center_hill);
pager=contentView.findViewById(R.id.pager);
pagerDots=contentView.findViewById(R.id.pager_dots);
pager.setAdapter(new PagerAdapter());
pager.setOffscreenPageLimit(3);
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels){
for(int i=0;i<pagerDots.getChildCount();i++){
float alpha;
if(i==position){
alpha=0.3f+0.7f*(1f-positionOffset);
}else if(i==position+1){
alpha=0.3f+0.7f*positionOffset;
}else{
alpha=0.3f;
}
pagerDots.getChildAt(i).setAlpha(alpha);
}
float parallaxProgress=(position+positionOffset)/2f;
artClouds.setTranslationX(V.dp(-27)*(position>=1 ? 1f : positionOffset));
artPlaneElephant.setTranslationX(V.dp(101.55f)*parallaxProgress);
artLeftHill.setTranslationX(V.dp(-88)*parallaxProgress);
artLeftHill.setTranslationY(V.dp(24)*parallaxProgress);
artRightHill.setTranslationX(V.dp(-88)*parallaxProgress);
artRightHill.setTranslationY(V.dp(-24)*parallaxProgress);
artCenterHill.setTranslationX(V.dp(-40)*parallaxProgress);
}
});
artContainer=contentView.findViewById(R.id.art_container);
blueFill=contentView.findViewById(R.id.blue_fill);
greenFill=contentView.findViewById(R.id.green_fill);
motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(contentView.findViewById(R.id.art_clouds), V.dp(-5), V.dp(5), V.dp(-5), V.dp(5)));
motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(contentView.findViewById(R.id.art_right_hill), V.dp(-15), V.dp(25), V.dp(-10), V.dp(10)));
motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(contentView.findViewById(R.id.art_left_hill), V.dp(-25), V.dp(15), V.dp(-15), V.dp(15)));
motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(contentView.findViewById(R.id.art_center_hill), V.dp(-14), V.dp(14), V.dp(-5), V.dp(25)));
motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(contentView.findViewById(R.id.art_plane_elephant), V.dp(-20), V.dp(12), V.dp(-20), V.dp(12)));
contentView.setSizeListener(new SizeListenerFrameLayout.OnSizeChangedListener(){
@Override
@@ -66,15 +104,16 @@ public class SplashFragment extends AppKitFragment{
private void onButtonClick(View v){
Bundle extras=new Bundle();
extras.putBoolean("signup", v.getId()==R.id.btn_get_started);
Nav.go(getActivity(), InstanceCatalogFragment.class, extras);
boolean isSignup=v.getId()==R.id.btn_get_started;
extras.putBoolean("signup", isSignup);
Nav.go(getActivity(), isSignup ? InstanceCatalogSignupFragment.class : InstanceChooserLoginFragment.class, extras);
}
private void updateArtSize(int w, int h){
float scale=w/(float)V.dp(412);
float scale=w/(float)V.dp(360);
artContainer.setScaleX(scale);
artContainer.setScaleY(scale);
blueFill.setScaleY(h/2f);
blueFill.setScaleY(artContainer.getBottom()-V.dp(90));
greenFill.setScaleY(h-artContainer.getBottom()+V.dp(90));
}
@@ -100,15 +139,91 @@ public class SplashFragment extends AppKitFragment{
return true;
}
@Override
protected void onShown(){
super.onShown();
motionEffect.activate();
private class PagerAdapter extends RecyclerView.Adapter<PagerViewHolder>{
@NonNull
@Override
public PagerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new PagerViewHolder(viewType);
}
@Override
public void onBindViewHolder(@NonNull PagerViewHolder holder, int position){}
@Override
public int getItemCount(){
return 3;
}
@Override
public int getItemViewType(int position){
return position;
}
}
@Override
protected void onHidden(){
super.onHidden();
motionEffect.deactivate();
private class PagerViewHolder extends RecyclerView.ViewHolder{
public PagerViewHolder(int page){
super(new LinearLayout(getActivity()));
LinearLayout ll=(LinearLayout) itemView;
ll.setOrientation(LinearLayout.VERTICAL);
int pad=V.dp(16);
ll.setPadding(pad, pad, pad, pad);
ll.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
TextView title=new TextView(getActivity());
title.setTextAppearance(R.style.m3_headline_medium);
title.setText(switch(page){
case 0 -> {
String src=getString(R.string.welcome_page1_title);
SpannableString ss=new SpannableString(src);
int start=src.indexOf("{logo}");
if(start!=-1){
LogoSpan span=new LogoSpan(getResources().getDrawable(R.drawable.splash_logo, getActivity().getTheme()));
ss.setSpan(span, start, start+6, 0);
}
yield ss;
}
case 1 -> getString(R.string.welcome_page2_title);
case 2 -> getString(R.string.welcome_page3_title);
default -> throw new IllegalStateException("Unexpected value: "+page);
});
title.setTextColor(0xFF17063B);
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(page==0 ? 46 : 36));
lp.bottomMargin=V.dp(page==0 ? 4 : 14);
ll.addView(title, lp);
TextView text=new TextView(getActivity());
text.setTextAppearance(R.style.m3_body_medium);
text.setText(switch(page){
case 0 -> R.string.welcome_page1_text;
case 1 -> R.string.welcome_page2_text;
case 2 -> R.string.welcome_page3_text;
default -> throw new IllegalStateException("Unexpected value: "+page);
});
text.setTextColor(0xFF17063B);
ll.addView(text, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
}
}
private class LogoSpan extends ReplacementSpan{
private final Drawable drawable;
private LogoSpan(Drawable drawable){
this.drawable=drawable;
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){
return drawable.getIntrinsicWidth();
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){
drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
canvas.save();
canvas.translate(x, y-V.dp(20));
drawable.draw(canvas);
canvas.restore();
}
}
}

View File

@@ -6,6 +6,7 @@ import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusDeletedEvent;
@@ -18,6 +19,8 @@ import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
@@ -134,6 +137,40 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
return null;
}
protected boolean shouldRemoveAccountPostsWhenUnfollowing(){
return false;
}
protected void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){
List<Status> toRemove=Stream.concat(data.stream(), preloadedData.stream())
.filter(s->s.account.id.equals(ev.postsByAccountID) || (s.reblog!=null && s.reblog.account.id.equals(ev.postsByAccountID)))
.collect(Collectors.toList());
for(Status s:toRemove){
removeStatus(s);
}
}
protected void removeStatus(Status status){
data.remove(status);
preloadedData.remove(status);
int index=-1;
for(int i=0;i<displayItems.size();i++){
if(status.id.equals(displayItems.get(i).parentID)){
index=i;
break;
}
}
if(index==-1)
return;
int lastIndex;
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(status.id))
break;
}
displayItems.subList(index, lastIndex).clear();
adapter.notifyItemRangeRemoved(index, lastIndex-index);
}
public class EventListener{
@Subscribe
@@ -165,28 +202,13 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
Status status=getStatusByID(ev.id);
if(status==null)
return;
data.remove(status);
preloadedData.remove(status);
int index=-1;
for(int i=0;i<displayItems.size();i++){
if(ev.id.equals(displayItems.get(i).parentID)){
index=i;
break;
}
}
if(index==-1)
return;
int lastIndex;
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(ev.id))
break;
}
displayItems.subList(index, lastIndex).clear();
adapter.notifyItemRangeRemoved(index, lastIndex-index);
removeStatus(status);
}
@Subscribe
public void onStatusCreated(StatusCreatedEvent ev){
if(!ev.accountID.equals(accountID))
return;
StatusListFragment.this.onStatusCreated(ev);
}
@@ -206,5 +228,14 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
}
}
}
@Subscribe
public void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){
if(!ev.accountID.equals(accountID))
return;
if(ev.isUnfollow && !shouldRemoveAccountPostsWhenUnfollowing())
return;
StatusListFragment.this.onRemoveAccountPostsEvent(ev);
}
}
}

View File

@@ -97,7 +97,7 @@ public class ThreadFragment extends StatusListFragment{
return statuses;
return statuses.stream().filter(status->{
for(Filter filter:filters){
if(filter.matches(status.getContentStatus().content))
if(filter.matches(status))
return false;
}
return true;

View File

@@ -17,11 +17,11 @@ public class DiscoverPostsFragment extends StatusListFragment{
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetTrendingStatuses(count)
currentRequest=new GetTrendingStatuses(offset, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
onDataLoaded(result, false);
onDataLoaded(result, !result.isEmpty());
}
}).exec(accountID);
}

View File

@@ -35,6 +35,8 @@ import java.util.stream.Collectors;
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.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
@@ -128,7 +130,7 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
}else{
progressVisibilityListener.onProgressVisibilityChanged(true);
currentRequest=new GetSearchResults(currentQuery, null, true)
.setCallback(new SimpleCallback<>(this){
.setCallback(new Callback<>(){
@Override
public void onSuccess(SearchResults result){
ArrayList<SearchResult> results=new ArrayList<>();
@@ -148,6 +150,17 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
unfilteredResults=results;
onDataLoaded(filterSearchResults(results), false);
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
Activity a=getActivity();
if(a==null)
return;
error.showToast(a);
if(progressVisibilityListener!=null)
progressVisibilityListener.onProgressVisibilityChanged(false);
}
})
.exec(accountID);
}
@@ -159,12 +172,11 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
super.updateList();
return;
}
imgLoader.deactivate();
UiUtils.updateList(prevDisplayItems, displayItems, list, adapter, (i1, i2)->i1.parentID.equals(i2.parentID) && i1.index==i2.index && i1.getType()==i2.getType());
boolean recent=isInRecentMode();
if(recent!=headerAdapter.isVisible())
headerAdapter.setVisible(recent);
imgLoader.activate();
imgLoader.forceUpdateImages();
prevDisplayItems=null;
}

View File

@@ -1,8 +1,8 @@
package org.joinmastodon.android.fragments.onboarding;
import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
@@ -12,19 +12,21 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.Button;
import android.widget.TextView;
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;
import org.joinmastodon.android.api.requests.accounts.ResendConfirmationEmail;
import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials;
import org.joinmastodon.android.api.session.AccountActivationInfo;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.HomeFragment;
import org.joinmastodon.android.fragments.SettingsFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
@@ -35,40 +37,50 @@ 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.AppKitFragment;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.utils.V;
public class AccountActivationFragment extends AppKitFragment{
public class AccountActivationFragment extends ToolbarFragment{
private String accountID;
private Button btn, backBtn;
private View buttonBar;
private Button openEmailBtn, resendBtn;
private View contentView;
private Handler uiHandler=new Handler(Looper.getMainLooper());
private Runnable pollRunnable=this::tryGetAccount;
private APIRequest currentRequest;
private Runnable resendTimer=this::updateResendTimer;
private long lastResendTime;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
setTitle(R.string.confirm_email_title);
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
lastResendTime=session.activationInfo!=null ? session.activationInfo.lastEmailConfirmationResend : 0;
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
public View onCreateContentView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
View view=inflater.inflate(R.layout.fragment_onboarding_activation, container, false);
btn=view.findViewById(R.id.btn_next);
btn.setOnClickListener(v->onButtonClick());
btn.setOnLongClickListener(v->{
openEmailBtn=view.findViewById(R.id.btn_next);
openEmailBtn.setOnClickListener(this::onOpenEmailClick);
openEmailBtn.setOnLongClickListener(v->{
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), SettingsFragment.class, args);
return true;
});
buttonBar=view.findViewById(R.id.button_bar);
view.findViewById(R.id.btn_back).setOnClickListener(v->onBackButtonClick());
resendBtn=view.findViewById(R.id.btn_resend);
resendBtn.setOnClickListener(this::onResendClick);
TextView text=view.findViewById(R.id.subtitle);
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
text.setText(getString(R.string.confirm_email_subtitle, session.activationInfo!=null ? session.activationInfo.email : "?"));
updateResendTimer();
contentView=view;
return view;
}
@@ -80,14 +92,32 @@ public class AccountActivationFragment extends AppKitFragment{
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight));
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
getToolbar().setBackground(null);
getToolbar().setElevation(0);
}
@Override
protected boolean canGoBack(){
return true;
}
@Override
public void onToolbarNavigationClick(){
new AccountSwitcherSheet(getActivity()).show();
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=27){
int inset=insets.getSystemWindowInsetBottom();
buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0);
contentView.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0);
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0));
}else{
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
@@ -111,7 +141,7 @@ public class AccountActivationFragment extends AppKitFragment{
}
}
private void onButtonClick(){
private void onOpenEmailClick(View v){
try{
startActivity(Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_EMAIL).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
}catch(ActivityNotFoundException x){
@@ -119,12 +149,21 @@ public class AccountActivationFragment extends AppKitFragment{
}
}
private void onBackButtonClick(){
private void onResendClick(View v){
new ResendConfirmationEmail(null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Object result){
Toast.makeText(getActivity(), R.string.resent_email, Toast.LENGTH_SHORT).show();
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
if(session.activationInfo==null){
session.activationInfo=new AccountActivationInfo("?", System.currentTimeMillis());
}else{
session.activationInfo.lastEmailConfirmationResend=System.currentTimeMillis();
}
lastResendTime=session.activationInfo.lastEmailConfirmationResend;
AccountSessionManager.getInstance().writeAccountsFile();
updateResendTimer();
}
@Override
@@ -152,7 +191,7 @@ public class AccountActivationFragment extends AppKitFragment{
AccountSessionManager mgr=AccountSessionManager.getInstance();
AccountSession session=mgr.getAccount(accountID);
mgr.removeAccount(accountID);
mgr.addAccount(mgr.getInstanceInfo(session.domain), session.token, result, session.app, true);
mgr.addAccount(mgr.getInstanceInfo(session.domain), session.token, result, session.app, null);
String newID=mgr.getLastActiveAccountID();
Bundle args=new Bundle();
args.putString("account", newID);
@@ -189,4 +228,25 @@ public class AccountActivationFragment extends AppKitFragment{
})
.exec(accountID);
}
@SuppressLint("DefaultLocale")
private void updateResendTimer(){
long sinceResend=System.currentTimeMillis()-lastResendTime;
if(sinceResend>59_000L){
resendBtn.setText(R.string.resend);
resendBtn.setEnabled(true);
return;
}
int seconds=(int)((60_000L-sinceResend)/1000L);
resendBtn.setText(String.format("%s (%d)", getString(R.string.resend), seconds));
if(resendBtn.isEnabled())
resendBtn.setEnabled(false);
resendBtn.postDelayed(resendTimer, 500);
}
@Override
public void onDestroyView(){
super.onDestroyView();
resendBtn.removeCallbacks(resendTimer);
}
}

View File

@@ -1,6 +1,7 @@
package org.joinmastodon.android.fragments.onboarding;
import android.app.Activity;
import android.graphics.Paint;
import android.graphics.Rect;
import android.os.Build;
import android.os.Bundle;
@@ -15,6 +16,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.jsoup.Jsoup;
@@ -33,6 +35,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
@@ -46,7 +49,7 @@ import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
public class GoogleMadeMeAddThisFragment extends AppKitFragment{
public class GoogleMadeMeAddThisFragment extends ToolbarFragment{
private UsableRecyclerView list;
private MergeRecyclerAdapter adapter;
private Button btn;
@@ -60,6 +63,7 @@ public class GoogleMadeMeAddThisFragment extends AppKitFragment{
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setRetainInstance(true);
setTitle(R.string.privacy_policy_title);
}
@Override
@@ -82,37 +86,24 @@ public class GoogleMadeMeAddThisFragment extends AppKitFragment{
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
View view=inflater.inflate(R.layout.fragment_onboarding_rules, container, false);
list=view.findViewById(R.id.list);
list.setLayoutManager(new LinearLayoutManager(getActivity()));
View headerView=inflater.inflate(R.layout.item_list_header, list, false);
TextView title=headerView.findViewById(R.id.title);
TextView subtitle=headerView.findViewById(R.id.subtitle);
headerView.findViewById(R.id.step_counter).setVisibility(View.GONE);
title.setText(R.string.privacy_policy_title);
subtitle.setText(R.string.privacy_policy_subtitle);
View headerView=inflater.inflate(R.layout.item_list_header_simple, list, false);
TextView text=headerView.findViewById(R.id.text);
text.setText(R.string.privacy_policy_subtitle);
adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SingleViewRecyclerAdapter(headerView));
adapter.addAdapter(itemsAdapter=new ItemsAdapter());
list.setAdapter(adapter);
list.setSelector(null);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if(parent.getChildViewHolder(view) instanceof ItemViewHolder){
outRect.left=outRect.right=V.dp(18.5f);
outRect.top=V.dp(16);
}
}
});
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3SurfaceVariant, 1, 56, 0, DividerItemDecoration.NOT_FIRST));
btn=view.findViewById(R.id.btn_next);
btn.setOnClickListener(v->onButtonClick());
buttonBar=view.findViewById(R.id.button_bar);
view.findViewById(R.id.btn_back).setOnClickListener(v->Nav.finish(this));
return view;
}
@@ -120,7 +111,15 @@ public class GoogleMadeMeAddThisFragment extends AppKitFragment{
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight));
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
getToolbar().setBackground(null);
getToolbar().setElevation(0);
}
protected void onButtonClick(){
@@ -192,24 +191,17 @@ public class GoogleMadeMeAddThisFragment extends AppKitFragment{
}
private class ItemViewHolder extends BindableViewHolder<Item> implements UsableRecyclerView.Clickable{
private final TextView domain, title;
private final ImageView favicon;
private final TextView title;
public ItemViewHolder(){
super(getActivity(), R.layout.item_privacy_policy_link, list);
domain=findViewById(R.id.domain);
title=findViewById(R.id.title);
favicon=findViewById(R.id.favicon);
itemView.setOutlineProvider(OutlineProviders.roundedRect(10));
itemView.setClipToOutline(true);
title.setPaintFlags(title.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG);
}
@Override
public void onBind(Item item){
domain.setText(item.domain);
title.setText(item.title);
ViewImageLoader.load(favicon, null, new UrlImageLoaderRequest(item.faviconUrl));
}
@Override

View File

@@ -1,42 +1,35 @@
package org.joinmastodon.android.fragments.onboarding;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.LocaleList;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.api.requests.catalog.GetCatalogCategories;
import org.joinmastodon.android.api.requests.catalog.GetCatalogInstances;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.catalog.CatalogCategory;
import org.joinmastodon.android.model.catalog.CatalogInstance;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.tabs.TabLayout;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import java.io.IOException;
import java.net.IDN;
import java.util.ArrayList;
import java.util.Collections;
@@ -44,47 +37,48 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.xml.parsers.DocumentBuilderFactory;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
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.fragments.BaseRecyclerFragment;
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.UsableRecyclerView;
import okhttp3.Call;
import okhttp3.Request;
import okhttp3.Response;
public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstance>{
private InstancesAdapter adapter;
private MergeRecyclerAdapter mergeAdapter;
private View headerView;
private CatalogInstance chosenInstance;
private List<CatalogInstance> filteredData=new ArrayList<>();
private Button nextButton;
private MastodonAPIRequest<?> getCategoriesRequest;
private EditText searchEdit;
private TabLayout categoriesList;
private Runnable searchDebouncer=this::onSearchChangedDebounced;
private String currentSearchQuery;
private String currentCategory="all";
private List<CatalogCategory> categories=new ArrayList<>();
private String loadingInstanceDomain;
private GetInstance loadingInstanceRequest;
private HashMap<String, Instance> instancesCache=new HashMap<>();
private ProgressDialog instanceProgressDialog;
private View buttonBar;
private boolean isSignup;
abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstance>{
protected RecyclerView.Adapter adapter;
protected MergeRecyclerAdapter mergeAdapter;
protected CatalogInstance chosenInstance;
protected Button nextButton;
protected EditText searchEdit;
protected Runnable searchDebouncer=this::onSearchChangedDebounced;
protected String currentSearchQuery;
protected String loadingInstanceDomain;
protected HashMap<String, Instance> instancesCache=new HashMap<>();
protected View buttonBar;
protected List<CatalogInstance> filteredData=new ArrayList<>();
protected GetInstance loadingInstanceRequest;
protected Call loadingInstanceRedirectRequest;
protected ProgressDialog instanceProgressDialog;
protected HashMap<String, String> redirects=new HashMap<>();
protected HashMap<String, String> redirectsInverse=new HashMap<>();
protected boolean isSignup;
protected CatalogInstance fakeInstance=new CatalogInstance();
private static final double DUNBAR=Math.log(800);
public InstanceCatalogFragment(){
super(R.layout.fragment_onboarding_common, 10);
public InstanceCatalogFragment(int layout, int perPage){
super(layout, perPage);
}
@Override
@@ -93,258 +87,9 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
isSignup=getArguments().getBoolean("signup");
}
@Override
public void onAttach(Context context){
super.onAttach(context);
setRefreshEnabled(false);
loadData();
}
protected abstract void proceedWithAuthOrSignup(Instance instance);
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetCatalogInstances(null, null)
.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)->{
double aa=Math.abs(DUNBAR-Math.log(a.lastWeekUsers));
double bb=Math.abs(DUNBAR-Math.log(b.lastWeekUsers));
return Double.compare(aa, bb);
});
}
// get the list of user-configured system languages
List<String> userLangs;
if(Build.VERSION.SDK_INT<24){
userLangs=Collections.singletonList(getResources().getConfiguration().locale.getLanguage());
}else{
LocaleList ll=getResources().getConfiguration().getLocales();
userLangs=new ArrayList<>(ll.size());
for(int i=0;i<ll.size();i++){
userLangs.add(ll.get(i).getLanguage());
}
}
// add instances in preferred languages to the top of the list, in the order of preference
ArrayList<CatalogInstance> sortedList=new ArrayList<>();
for(String lang:userLangs){
List<CatalogInstance> langInstances=byLang.remove(lang);
if(langInstances!=null){
sortedList.addAll(langInstances);
}
}
// sort the remaining language groups by aggregate lastWeekUsers
class InstanceGroup{
public int activeUsers;
public List<CatalogInstance> instances;
}
byLang.values().stream().map(il->{
InstanceGroup group=new InstanceGroup();
group.instances=il;
for(CatalogInstance instance:il){
group.activeUsers+=instance.lastWeekUsers;
}
return group;
}).sorted(Comparator.comparingInt((InstanceGroup g)->g.activeUsers).reversed()).forEachOrdered(ig->sortedList.addAll(ig.instances));
onDataLoaded(sortedList, false);
updateFilteredList();
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
onDataLoaded(Collections.emptyList(), false);
}
})
.execNoAuth("");
getCategoriesRequest=new GetCatalogCategories(null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<CatalogCategory> result){
getCategoriesRequest=null;
CatalogCategory all=new CatalogCategory();
all.category="all";
categories.add(all);
result.stream().sorted(Comparator.comparingInt((CatalogCategory cc)->cc.serversCount).reversed()).forEach(categories::add);
updateCategories();
}
@Override
public void onError(ErrorResponse error){
getCategoriesRequest=null;
error.showToast(getActivity());
CatalogCategory all=new CatalogCategory();
all.category="all";
categories.add(all);
updateCategories();
}
})
.execNoAuth("");
}
private void updateCategories(){
categoriesList.removeAllTabs();
for(CatalogCategory cat:categories){
int titleRes=getTitleForCategory(cat.category);
TabLayout.Tab tab=categoriesList.newTab().setText(titleRes!=0 ? getString(titleRes) : cat.category).setCustomView(R.layout.item_instance_category);
ImageView emoji=tab.getCustomView().findViewById(R.id.emoji);
emoji.setImageResource(getEmojiForCategory(cat.category));
categoriesList.addTab(tab);
}
}
@Override
public void onDestroy(){
super.onDestroy();
if(getCategoriesRequest!=null)
getCategoriesRequest.cancel();
}
@Override
protected RecyclerView.Adapter getAdapter(){
headerView=getActivity().getLayoutInflater().inflate(R.layout.header_onboarding_instance_catalog, list, false);
searchEdit=headerView.findViewById(R.id.search_edit);
categoriesList=headerView.findViewById(R.id.categories_list);
categoriesList.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener(){
@Override
public void onTabSelected(TabLayout.Tab tab){
CatalogCategory category=categories.get(tab.getPosition());
currentCategory=category.category;
updateFilteredList();
}
@Override
public void onTabUnselected(TabLayout.Tab tab){
}
@Override
public void onTabReselected(TabLayout.Tab tab){
}
});
searchEdit.setOnEditorActionListener(this::onSearchEnterPressed);
searchEdit.addTextChangedListener(new TextWatcher(){
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
searchEdit.removeCallbacks(searchDebouncer);
searchEdit.postDelayed(searchDebouncer, 300);
}
@Override
public void afterTextChanged(Editable s){
}
});
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView));
mergeAdapter.addAdapter(adapter=new InstancesAdapter());
return mergeAdapter;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
nextButton=view.findViewById(R.id.btn_next);
nextButton.setOnClickListener(this::onNextClick);
nextButton.setEnabled(chosenInstance!=null);
view.findViewById(R.id.btn_back).setOnClickListener(v->Nav.finish(this));
list.setItemAnimator(new BetterItemAnimator());
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 16, 16, DividerItemDecoration.NOT_FIRST));
view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight));
buttonBar=view.findViewById(R.id.button_bar);
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight));
}
private void onNextClick(View v){
String domain=chosenInstance.domain;
Instance instance=instancesCache.get(domain);
if(instance!=null){
proceedWithAuthOrSignup(instance);
}else{
showProgressDialog();
if(!domain.equals(loadingInstanceDomain)){
loadInstanceInfo(domain);
}
}
}
private void proceedWithAuthOrSignup(Instance instance){
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0);
if(isSignup){
Bundle args=new Bundle();
args.putParcelable("instance", Parcels.wrap(instance));
Nav.go(getActivity(), InstanceRulesFragment.class, args);
}else{
AccountSessionManager.getInstance().authenticate(getActivity(), instance);
}
}
// private String getEmojiForCategory(String category){
// return switch(category){
// case "all" -> "💬";
// case "academia" -> "📚";
// case "activism" -> "✊";
// case "food" -> "🍕";
// case "furry" -> "🦁";
// case "games" -> "🕹";
// case "general" -> "🐘";
// case "journalism" -> "📰";
// case "lgbt" -> "🏳️‍🌈";
// case "regional" -> "📍";
// case "art" -> "🎨";
// case "music" -> "🎼";
// case "tech" -> "📱";
// default -> "❓";
// };
// }
private int getEmojiForCategory(String category){
return switch(category){
case "all" -> R.drawable.ic_category_all;
case "academia" -> R.drawable.ic_category_academia;
case "activism" -> R.drawable.ic_category_activism;
case "food" -> R.drawable.ic_category_food;
case "furry" -> R.drawable.ic_category_furry;
case "games" -> R.drawable.ic_category_games;
case "general" -> R.drawable.ic_category_general;
case "journalism" -> R.drawable.ic_category_journalism;
case "lgbt" -> R.drawable.ic_category_lgbt;
case "regional" -> R.drawable.ic_category_regional;
case "art" -> R.drawable.ic_category_art;
case "music" -> R.drawable.ic_category_music;
case "tech" -> R.drawable.ic_category_tech;
default -> R.drawable.ic_category_unknown;
};
}
private int getTitleForCategory(String category){
return switch(category){
case "all" -> R.string.category_all;
case "academia" -> R.string.category_academia;
case "activism" -> R.string.category_activism;
case "food" -> R.string.category_food;
case "furry" -> R.string.category_furry;
case "games" -> R.string.category_games;
case "general" -> R.string.category_general;
case "journalism" -> R.string.category_journalism;
case "lgbt" -> R.string.category_lgbt;
case "regional" -> R.string.category_regional;
case "art" -> R.string.category_art;
case "music" -> R.string.category_music;
case "tech" -> R.string.category_tech;
default -> 0;
};
}
private boolean onSearchEnterPressed(TextView v, int actionId, KeyEvent event){
protected boolean onSearchEnterPressed(TextView v, int actionId, KeyEvent event){
if(event!=null && event.getAction()!=KeyEvent.ACTION_DOWN)
return true;
currentSearchQuery=searchEdit.getText().toString().toLowerCase();
@@ -353,73 +98,80 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
Instance instance=instancesCache.get(normalizeInstanceDomain(currentSearchQuery));
if(instance==null){
showProgressDialog();
loadInstanceInfo(currentSearchQuery);
loadInstanceInfo(currentSearchQuery, false);
}else{
proceedWithAuthOrSignup(instance);
}
return true;
}
private void onSearchChangedDebounced(){
protected void onSearchChangedDebounced(){
currentSearchQuery=searchEdit.getText().toString().toLowerCase();
updateFilteredList();
loadInstanceInfo(currentSearchQuery);
loadInstanceInfo(currentSearchQuery, false);
}
private void updateFilteredList(){
ArrayList<CatalogInstance> prevData=new ArrayList<>(filteredData);
filteredData.clear();
for(CatalogInstance instance:data){
if(currentCategory.equals("all") || instance.categories.contains(currentCategory)){
if(TextUtils.isEmpty(currentSearchQuery) || instance.domain.contains(currentSearchQuery)){
if(instance.domain.equals(currentSearchQuery) || !isSignup || !instance.approvalRequired)
filteredData.add(instance);
}
protected List<CatalogInstance> sortInstances(List<CatalogInstance> result){
Map<String, List<CatalogInstance>> byLang=result.stream().collect(Collectors.groupingBy(ci->ci.language));
for(List<CatalogInstance> group:byLang.values()){
Collections.sort(group, (a, b)->{
double aa=Math.abs(DUNBAR-Math.log(a.lastWeekUsers));
double bb=Math.abs(DUNBAR-Math.log(b.lastWeekUsers));
return Double.compare(aa, bb);
});
}
// get the list of user-configured system languages
List<String> userLangs;
if(Build.VERSION.SDK_INT<24){
userLangs=Collections.singletonList(getResources().getConfiguration().locale.getLanguage());
}else{
LocaleList ll=getResources().getConfiguration().getLocales();
userLangs=new ArrayList<>(ll.size());
for(int i=0;i<ll.size();i++){
userLangs.add(ll.get(i).getLanguage());
}
}
DiffUtil.calculateDiff(new DiffUtil.Callback(){
@Override
public int getOldListSize(){
return prevData.size();
// add instances in preferred languages to the top of the list, in the order of preference
ArrayList<CatalogInstance> sortedList=new ArrayList<>();
for(String lang:userLangs){
List<CatalogInstance> langInstances=byLang.remove(lang);
if(langInstances!=null){
sortedList.addAll(langInstances);
}
@Override
public int getNewListSize(){
return filteredData.size();
}
// sort the remaining language groups by aggregate lastWeekUsers
class InstanceGroup{
public int activeUsers;
public List<CatalogInstance> instances;
}
byLang.values().stream().map(il->{
InstanceGroup group=new InstanceGroup();
group.instances=il;
for(CatalogInstance instance:il){
group.activeUsers+=instance.lastWeekUsers;
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){
return prevData.get(oldItemPosition)==filteredData.get(newItemPosition);
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){
return prevData.get(oldItemPosition)==filteredData.get(newItemPosition);
}
}).dispatchUpdatesTo(adapter);
return group;
}).sorted(Comparator.comparingInt((InstanceGroup g)->g.activeUsers).reversed()).forEachOrdered(ig->sortedList.addAll(ig.instances));
return sortedList;
}
private void showProgressDialog(){
protected abstract void updateFilteredList();
protected void showProgressDialog(){
instanceProgressDialog=new ProgressDialog(getActivity());
instanceProgressDialog.setMessage(getString(R.string.loading_instance));
instanceProgressDialog.setOnCancelListener(dialog->{
if(loadingInstanceRequest!=null){
loadingInstanceRequest.cancel();
loadingInstanceRequest=null;
loadingInstanceDomain=null;
}
});
instanceProgressDialog.setOnCancelListener(dialog->cancelLoadingInstanceInfo());
instanceProgressDialog.show();
}
private String normalizeInstanceDomain(String _domain){
protected String normalizeInstanceDomain(String _domain){
if(TextUtils.isEmpty(_domain))
return null;
if(_domain.contains(":")){
try{
_domain=Uri.parse(_domain).getAuthority();
}catch(Exception ignore){}
}catch(Exception ignore){
}
if(TextUtils.isEmpty(_domain))
return null;
}
@@ -429,15 +181,19 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
}catch(IllegalArgumentException x){
return null;
}
if(redirects.containsKey(domain))
return redirects.get(domain);
return domain;
}
private void loadInstanceInfo(String _domain){
protected void loadInstanceInfo(String _domain, boolean isFromRedirect){
if(TextUtils.isEmpty(_domain))
return;
String domain=normalizeInstanceDomain(_domain);
Instance cachedInstance=instancesCache.get(domain);
if(cachedInstance!=null){
for(CatalogInstance ci:filteredData){
if(ci.domain.equals(domain))
for(CatalogInstance ci : filteredData){
if(ci.domain.equals(domain) && ci!=fakeInstance)
return;
}
CatalogInstance ci=cachedInstance.toCatalogInstance();
@@ -446,56 +202,155 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
return;
}
if(loadingInstanceDomain!=null){
if(loadingInstanceDomain.equals(domain))
if(loadingInstanceDomain.equals(domain)){
return;
else
loadingInstanceRequest.cancel();
}else{
cancelLoadingInstanceInfo();
}
}
loadingInstanceDomain=domain;
loadingInstanceRequest=new GetInstance();
loadingInstanceRequest.setCallback(new Callback<>(){
@Override
public void onSuccess(Instance result){
loadingInstanceRequest=null;
loadingInstanceDomain=null;
result.uri=domain; // needed for instances that use domain redirection
instancesCache.put(domain, result);
if(instanceProgressDialog!=null){
instanceProgressDialog.dismiss();
instanceProgressDialog=null;
proceedWithAuthOrSignup(result);
}
if(domain.equals(currentSearchQuery)){
boolean found=false;
for(CatalogInstance ci:filteredData){
if(ci.domain.equals(domain)){
found=true;
break;
}
}
if(!found){
CatalogInstance ci=result.toCatalogInstance();
filteredData.add(0, ci);
adapter.notifyItemInserted(0);
}
@Override
public void onSuccess(Instance result){
loadingInstanceRequest=null;
loadingInstanceDomain=null;
result.uri=domain; // needed for instances that use domain redirection
instancesCache.put(domain, result);
if(instanceProgressDialog!=null){
instanceProgressDialog.dismiss();
instanceProgressDialog=null;
proceedWithAuthOrSignup(result);
}
if(Objects.equals(domain, currentSearchQuery) || Objects.equals(currentSearchQuery, redirects.get(domain)) || Objects.equals(currentSearchQuery, redirectsInverse.get(domain))){
boolean found=false;
for(CatalogInstance ci:filteredData){
if(ci.domain.equals(domain) && ci!=fakeInstance){
found=true;
break;
}
}
if(!found){
CatalogInstance ci=result.toCatalogInstance();
if(filteredData.size()==1 && filteredData.get(0)==fakeInstance){
filteredData.set(0, ci);
adapter.notifyItemChanged(0);
}else{
filteredData.add(0, ci);
adapter.notifyItemInserted(0);
}
}
}
}
@Override
public void onError(ErrorResponse error){
loadingInstanceRequest=null;
loadingInstanceDomain=null;
if(instanceProgressDialog!=null){
instanceProgressDialog.dismiss();
instanceProgressDialog=null;
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.error)
.setMessage(getString(R.string.not_a_mastodon_instance, domain)+"\n\n"+((MastodonErrorResponse)error).error)
.setPositiveButton(R.string.ok, null)
.show();
@Override
public void onError(ErrorResponse error){
loadingInstanceRequest=null;
if(!isFromRedirect && error instanceof MastodonErrorResponse me && me.httpStatus==404){
fetchDomainFromHostMetaAndMaybeRetry(domain, error);
return;
}
loadingInstanceDomain=null;
showInstanceInfoLoadError(domain, error);
if(fakeInstance!=null){
fakeInstance.description=getString(R.string.error);
if(filteredData.size()>0 && filteredData.get(0)==fakeInstance){
if(list.findViewHolderForAdapterPosition(1) instanceof BindableViewHolder<?> ivh){
ivh.rebind();
}
}
}).execNoAuth(domain);
}
}
}).execNoAuth(domain);
}
private void cancelLoadingInstanceInfo(){
if(loadingInstanceRequest!=null){
loadingInstanceRequest.cancel();
loadingInstanceRequest=null;
}
if(loadingInstanceRedirectRequest!=null){
loadingInstanceRedirectRequest.cancel();
loadingInstanceRedirectRequest=null;
}
loadingInstanceDomain=null;
if(instanceProgressDialog!=null){
instanceProgressDialog.dismiss();
instanceProgressDialog=null;
}
}
private void showInstanceInfoLoadError(String domain, Object error){
if(instanceProgressDialog!=null){
instanceProgressDialog.dismiss();
instanceProgressDialog=null;
String additionalInfo;
if(error instanceof MastodonErrorResponse me){
additionalInfo="\n\n"+me.error;
}else if(error instanceof Throwable t){
additionalInfo="\n\n"+t.getLocalizedMessage();
}else{
additionalInfo="";
}
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.error)
.setMessage(getString(R.string.not_a_mastodon_instance, domain)+additionalInfo)
.setPositiveButton(R.string.ok, null)
.show();
}
}
private void fetchDomainFromHostMetaAndMaybeRetry(String domain, Object origError){
String url="https://"+domain+"/.well-known/host-meta";
Request req=new Request.Builder()
.url(url)
.build();
loadingInstanceRedirectRequest=MastodonAPIController.getHttpClient().newCall(req);
loadingInstanceRedirectRequest.enqueue(new okhttp3.Callback(){
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e){
loadingInstanceRedirectRequest=null;
loadingInstanceDomain=null;
Activity a=getActivity();
if(a==null)
return;
a.runOnUiThread(()->showInstanceInfoLoadError(domain, e));
}
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{
loadingInstanceRedirectRequest=null;
loadingInstanceDomain=null;
Activity a=getActivity();
if(a==null)
return;
try(response){
if(!response.isSuccessful()){
a.runOnUiThread(()->showInstanceInfoLoadError(domain, response.code()+" "+response.message()));
return;
}
InputSource source=new InputSource(response.body().charStream());
Document doc=DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(source);
NodeList list=doc.getElementsByTagName("Link");
for(int i=0; i<list.getLength(); i++){
if(list.item(i) instanceof Element el){
String template=el.getAttribute("template");
if("lrdd".equals(el.getAttribute("rel")) && !TextUtils.isEmpty(template) && template.contains("{uri}")){
Uri uri=Uri.parse(template.replace("{uri}", "qwe"));
String redirectDomain=normalizeInstanceDomain(uri.getHost());
redirects.put(domain, redirectDomain);
redirectsInverse.put(redirectDomain, domain);
a.runOnUiThread(()->loadInstanceInfo(redirectDomain, true));
return;
}
}
}
a.runOnUiThread(()->showInstanceInfoLoadError(domain, origError));
}catch(Exception x){
a.runOnUiThread(()->showInstanceInfoLoadError(domain, x));
}
}
});
}
@Override
@@ -509,78 +364,26 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
}
}
private class InstancesAdapter extends UsableRecyclerView.Adapter<InstanceViewHolder>{
public InstancesAdapter(){
super(imgLoader);
}
@NonNull
@Override
public InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new InstanceViewHolder();
}
@Override
public void onBindViewHolder(InstanceViewHolder holder, int position){
holder.bind(filteredData.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return filteredData.size();
}
@Override
public int getItemViewType(int position){
return -1;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
nextButton=view.findViewById(R.id.btn_next);
nextButton.setOnClickListener(this::onNextClick);
nextButton.setEnabled(chosenInstance!=null);
buttonBar=view.findViewById(R.id.button_bar);
setRefreshEnabled(false);
}
private class InstanceViewHolder extends BindableViewHolder<CatalogInstance> implements UsableRecyclerView.Clickable{
private final TextView title, description, userCount, lang;
private final RadioButton radioButton;
public InstanceViewHolder(){
super(getActivity(), R.layout.item_instance_catalog, list);
title=findViewById(R.id.title);
description=findViewById(R.id.description);
userCount=findViewById(R.id.user_count);
lang=findViewById(R.id.lang);
radioButton=findViewById(R.id.radiobtn);
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N){
UiUtils.fixCompoundDrawableTintOnAndroid6(userCount);
UiUtils.fixCompoundDrawableTintOnAndroid6(lang);
protected void onNextClick(View v){
String domain=chosenInstance.domain;
Instance instance=instancesCache.get(domain);
if(instance!=null){
proceedWithAuthOrSignup(instance);
}else{
showProgressDialog();
if(!domain.equals(loadingInstanceDomain)){
loadInstanceInfo(domain, false);
}
}
@Override
public void onBind(CatalogInstance item){
title.setText(item.normalizedDomain);
description.setText(item.description);
userCount.setText(UiUtils.abbreviateNumber(item.totalUsers));
lang.setText(item.language.toUpperCase());
radioButton.setChecked(chosenInstance==item);
}
@Override
public void onClick(){
if(chosenInstance==item)
return;
if(chosenInstance!=null){
int idx=filteredData.indexOf(chosenInstance);
if(idx!=-1){
RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(mergeAdapter.getPositionForAdapter(adapter)+idx);
if(holder instanceof InstanceViewHolder ivh){
ivh.radioButton.setChecked(false);
}
}
}
radioButton.setChecked(true);
if(chosenInstance==null)
nextButton.setEnabled(true);
chosenInstance=item;
loadInstanceInfo(chosenInstance.domain);
}
}
}

View File

@@ -0,0 +1,713 @@
package org.joinmastodon.android.fragments.onboarding;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.Menu;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.inputmethod.InputMethodManager;
import android.widget.HorizontalScrollView;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.PopupMenu;
import android.widget.RadioButton;
import android.widget.RelativeLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.catalog.GetCatalogCategories;
import org.joinmastodon.android.api.requests.catalog.GetCatalogInstances;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.catalog.CatalogCategory;
import org.joinmastodon.android.model.catalog.CatalogInstance;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FilterChipView;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
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.fragments.OnBackPressedListener;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class InstanceCatalogSignupFragment extends InstanceCatalogFragment implements OnBackPressedListener{
private MastodonAPIRequest<?> getCategoriesRequest;
private String currentCategory="all";
private List<CatalogCategory> categories=new ArrayList<>();
private View topBar;
private List<String> languages=Collections.emptyList();
private PopupMenu langFilterMenu, speedFilterMenu;
private SignupSpeedFilter currentSignupSpeedFilter=SignupSpeedFilter.INSTANT;
private String currentLanguage=null;
private boolean searchQueryMode;
private LinearLayout filtersWrap;
private HorizontalScrollView filtersScroll;
private ImageButton backBtn, clearSearchBtn;
private View focusThing;
private FilterChipView categoryGeneral, categorySpecialInterests;
private List<FilterChipView> regionalFilters;
private CatalogInstance.Region chosenRegion;
private CategoryChoice categoryChoice;
public InstanceCatalogSignupFragment(){
super(R.layout.fragment_onboarding_common, 10);
}
@Override
public void onAttach(Context context){
super.onAttach(context);
setRefreshEnabled(false);
setRetainInstance(true);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
loadData();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetCatalogInstances(null, null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<CatalogInstance> result){
if(getActivity()==null)
return;
onDataLoaded(sortInstances(result), false);
if(langFilterMenu!=null){
Menu menu=langFilterMenu.getMenu();
menu.clear();
menu.add(0, 0, 0, R.string.server_filter_any_language);
languages=result.stream().map(i->i.language).distinct().filter(s->s.length()>0).sorted().collect(Collectors.toList());
int i=1;
for(String lang:languages){
Locale locale=Locale.forLanguageTag(lang);
String name=locale.getDisplayLanguage(locale);
if(name.equals(lang))
name=lang.toUpperCase();
else
name=name.substring(0, 1).toUpperCase()+name.substring(1);
menu.add(0, i, 0, name);
i++;
}
}
updateFilteredList();
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
onDataLoaded(Collections.emptyList(), false);
}
})
.execNoAuth("");
getCategoriesRequest=new GetCatalogCategories(null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<CatalogCategory> result){
getCategoriesRequest=null;
CatalogCategory all=new CatalogCategory();
all.category="all";
categories.add(all);
result.stream().sorted(Comparator.comparingInt((CatalogCategory cc)->cc.serversCount).reversed()).forEach(categories::add);
updateCategories();
}
@Override
public void onError(ErrorResponse error){
getCategoriesRequest=null;
error.showToast(getActivity());
CatalogCategory all=new CatalogCategory();
all.category="all";
categories.add(all);
updateCategories();
}
})
.execNoAuth("");
}
private void updateCategories(){
// categoriesList.removeAllTabs();
// for(CatalogCategory cat:categories){
// int titleRes=getTitleForCategory(cat.category);
// TabLayout.Tab tab=categoriesList.newTab().setText(titleRes!=0 ? getString(titleRes) : cat.category).setCustomView(R.layout.item_instance_category);
// ImageView emoji=tab.getCustomView().findViewById(R.id.emoji);
// emoji.setImageResource(getEmojiForCategory(cat.category));
// categoriesList.addTab(tab);
// }
}
@Override
public void onDestroy(){
super.onDestroy();
if(getCategoriesRequest!=null)
getCategoriesRequest.cancel();
}
@Override
protected RecyclerView.Adapter getAdapter(){
View headerView=new View(getActivity());
headerView.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1));
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView));
mergeAdapter.addAdapter(adapter=new InstancesAdapter());
return mergeAdapter;
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
backBtn=view.findViewById(R.id.btn_back);
backBtn.setOnClickListener(v->{
if(searchQueryMode){
setSearchQueryMode(false);
}else{
Nav.finish(this);
}
});
clearSearchBtn=view.findViewById(R.id.clear);
clearSearchBtn.setOnClickListener(v->searchEdit.setText(""));
nextButton.setEnabled(true);
list.setItemAnimator(new BetterItemAnimator());
setStatusBarColor(0);
topBar=view.findViewById(R.id.top_bar);
LayerDrawable topBg=(LayerDrawable) topBar.getBackground().mutate();
topBar.setBackground(topBg);
Drawable topOverlay=topBg.findDrawableByLayerId(R.id.color_overlay);
topOverlay.setAlpha(0);
LayerDrawable btmBg=(LayerDrawable) buttonBar.getBackground().mutate();
buttonBar.setBackground(btmBg);
Drawable btmOverlay=btmBg.findDrawableByLayerId(R.id.color_overlay);
btmOverlay.setAlpha(0);
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
private boolean isAtTop=true;
private Animator currentPanelsAnim;
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
boolean newAtTop=recyclerView.getChildCount()==0 || (recyclerView.getChildAdapterPosition(recyclerView.getChildAt(0))==0 && recyclerView.getChildAt(0).getTop()==recyclerView.getPaddingTop());
if(newAtTop!=isAtTop){
isAtTop=newAtTop;
if(currentPanelsAnim!=null)
currentPanelsAnim.cancel();
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofInt(topOverlay, "alpha", isAtTop ? 0 : 20),
ObjectAnimator.ofInt(btmOverlay, "alpha", isAtTop ? 0 : 20),
ObjectAnimator.ofFloat(topBar, View.TRANSLATION_Z, isAtTop ? 0 : V.dp(3)),
ObjectAnimator.ofFloat(buttonBar, View.TRANSLATION_Z, isAtTop ? 0 : V.dp(3))
);
set.setDuration(150);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
currentPanelsAnim=null;
}
});
set.start();
currentPanelsAnim=set;
}
}
});
searchEdit=view.findViewById(R.id.search_edit);
searchEdit.setOnEditorActionListener(this::onSearchEnterPressed);
searchEdit.addTextChangedListener(new TextWatcher(){
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
searchEdit.removeCallbacks(searchDebouncer);
searchEdit.postDelayed(searchDebouncer, 300);
}
@Override
public void afterTextChanged(Editable s){
if((clearSearchBtn.getVisibility()==View.VISIBLE)!=(s.length()>0)){
clearSearchBtn.setVisibility(s.length()>0 ? View.VISIBLE : View.GONE);
}
}
});
searchEdit.setOnFocusChangeListener((v, hasFocus)->{
if(hasFocus && !searchQueryMode){
setSearchQueryMode(true);
}
});
FilterChipView langFilter=new FilterChipView(getActivity());
langFilter.setDrawableEnd(R.drawable.ic_baseline_arrow_drop_down_18);
if(currentLanguage==null){
langFilter.setText(R.string.server_filter_any_language);
}else{
Locale locale=Locale.forLanguageTag(currentLanguage);
langFilter.setText(locale.getDisplayLanguage(locale));
langFilter.setSelected(true);
}
langFilterMenu=new PopupMenu(getContext(), langFilter);
langFilter.setOnTouchListener(langFilterMenu.getDragToOpenListener());
langFilter.setOnClickListener(v->langFilterMenu.show());
filtersWrap=view.findViewById(R.id.filters_container);
filtersScroll=view.findViewById(R.id.filters_scroll);
filtersWrap.addView(langFilter, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
FilterChipView speedFilter=new FilterChipView(getActivity());
speedFilter.setDrawableEnd(R.drawable.ic_baseline_arrow_drop_down_18);
speedFilterMenu=new PopupMenu(getContext(), speedFilter);
speedFilterMenu.getMenu().add(0, 0, 0, R.string.server_filter_any_signup_speed);
speedFilterMenu.getMenu().add(0, 1, 0, R.string.server_filter_instant_signup);
speedFilterMenu.getMenu().add(0, 2, 0, R.string.server_filter_manual_review);
speedFilter.setOnTouchListener(speedFilterMenu.getDragToOpenListener());
speedFilter.setOnClickListener(v->speedFilterMenu.show());
speedFilter.setText(switch(currentSignupSpeedFilter){
case ANY -> R.string.server_filter_any_signup_speed;
case INSTANT -> R.string.server_filter_instant_signup;
case REVIEWED -> R.string.server_filter_manual_review;
});
speedFilter.setSelected(currentSignupSpeedFilter!=SignupSpeedFilter.ANY);
filtersWrap.addView(speedFilter, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
speedFilterMenu.setOnMenuItemClickListener(item->{
speedFilter.setText(item.getTitle());
speedFilter.setSelected(item.getItemId()>0);
currentSignupSpeedFilter=SignupSpeedFilter.values()[item.getItemId()];
updateFilteredList();
return true;
});
langFilterMenu.setOnMenuItemClickListener(item->{
langFilter.setText(item.getTitle());
langFilter.setSelected(item.getItemId()>0);
currentLanguage=item.getItemId()==0 ? null : languages.get(item.getItemId()-1);
updateFilteredList();
return true;
});
View divider=new View(getActivity());
divider.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Outline));
filtersWrap.addView(divider, new LinearLayout.LayoutParams(V.dp(.5f), ViewGroup.LayoutParams.MATCH_PARENT));
categoryGeneral=new FilterChipView(getActivity());
categoryGeneral.setText(R.string.category_general);
categoryGeneral.setTag(CategoryChoice.GENERAL);
categoryGeneral.setOnClickListener(this::onCategoryFilterClick);
categoryGeneral.setSelected(categoryChoice==CategoryChoice.GENERAL);
filtersWrap.addView(categoryGeneral, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
categorySpecialInterests=new FilterChipView(getActivity());
categorySpecialInterests.setText(R.string.category_special_interests);
categorySpecialInterests.setTag(CategoryChoice.SPECIAL);
categorySpecialInterests.setOnClickListener(this::onCategoryFilterClick);
categorySpecialInterests.setSelected(categoryChoice==CategoryChoice.SPECIAL);
filtersWrap.addView(categorySpecialInterests, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
regionalFilters=Arrays.stream(CatalogInstance.Region.values()).map(r->{
FilterChipView fv=new FilterChipView(getActivity());
fv.setTag(r);
fv.setText(switch(r){
case EUROPE -> R.string.server_filter_region_europe;
case NORTH_AMERICA -> R.string.server_filter_region_north_america;
case SOUTH_AMERICA -> R.string.server_filter_region_south_america;
case AFRICA -> R.string.server_filter_region_africa;
case ASIA -> R.string.server_filter_region_asia;
case OCEANIA -> R.string.server_filter_region_oceania;
});
fv.setSelected(r==chosenRegion);
fv.setOnClickListener(this::onRegionFilterClick);
filtersWrap.addView(fv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
return fv;
}).collect(Collectors.toList());
focusThing=view.findViewById(R.id.focus_thing);
focusThing.requestFocus();
}
private void onRegionFilterClick(View v){
CatalogInstance.Region r=(CatalogInstance.Region) v.getTag();
if(chosenRegion==r){
chosenRegion=null;
v.setSelected(false);
}else{
if(chosenRegion!=null)
filtersWrap.findViewWithTag(chosenRegion).setSelected(false);
chosenRegion=r;
v.setSelected(true);
}
updateFilteredList();
}
private void onCategoryFilterClick(View v){
CategoryChoice c=(CategoryChoice) v.getTag();
if(categoryChoice==c){
categoryChoice=null;
v.setSelected(false);
}else{
if(categoryChoice!=null)
filtersWrap.findViewWithTag(categoryChoice).setSelected(false);
categoryChoice=c;
v.setSelected(true);
}
updateFilteredList();
}
@Override
protected void onNextClick(View v){
if(chosenInstance==null){
String lang=Locale.getDefault().getLanguage();
List<CatalogInstance> instances=data.stream().filter(ci->!ci.approvalRequired && ("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general"))) && (lang.equals(ci.language) || (ci.languages!=null && ci.languages.contains(lang)))).collect(Collectors.toList());
if(instances.isEmpty()){
instances=data.stream().filter(ci->!ci.approvalRequired && ("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general")))).collect(Collectors.toList());
}
if(instances.isEmpty()){
return;
}
chosenInstance=instances.get(new Random().nextInt(instances.size()));
}
super.onNextClick(v);
}
@Override
protected void proceedWithAuthOrSignup(Instance instance){
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0);
if(!instance.registrations){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.error)
.setMessage(R.string.instance_signup_closed)
.setPositiveButton(R.string.ok, null)
.show();
return;
}
Bundle args=new Bundle();
args.putParcelable("instance", Parcels.wrap(instance));
Nav.go(getActivity(), InstanceRulesFragment.class, args);
}
// private String getEmojiForCategory(String category){
// return switch(category){
// case "all" -> "💬";
// case "academia" -> "📚";
// case "activism" -> "✊";
// case "food" -> "🍕";
// case "furry" -> "🦁";
// case "games" -> "🕹";
// case "general" -> "🐘";
// case "journalism" -> "📰";
// case "lgbt" -> "🏳️‍🌈";
// case "regional" -> "📍";
// case "art" -> "🎨";
// case "music" -> "🎼";
// case "tech" -> "📱";
// default -> "❓";
// };
// }
private int getEmojiForCategory(String category){
return switch(category){
case "all" -> R.drawable.ic_category_all;
case "academia" -> R.drawable.ic_category_academia;
case "activism" -> R.drawable.ic_category_activism;
case "food" -> R.drawable.ic_category_food;
case "furry" -> R.drawable.ic_category_furry;
case "games" -> R.drawable.ic_category_games;
case "general" -> R.drawable.ic_category_general;
case "journalism" -> R.drawable.ic_category_journalism;
case "lgbt" -> R.drawable.ic_category_lgbt;
case "regional" -> R.drawable.ic_category_regional;
case "art" -> R.drawable.ic_category_art;
case "music" -> R.drawable.ic_category_music;
case "tech" -> R.drawable.ic_category_tech;
default -> R.drawable.ic_category_unknown;
};
}
private int getTitleForCategory(String category){
return switch(category){
case "all" -> R.string.category_all;
case "academia" -> R.string.category_academia;
case "activism" -> R.string.category_activism;
case "food" -> R.string.category_food;
case "furry" -> R.string.category_furry;
case "games" -> R.string.category_games;
case "general" -> R.string.category_general;
case "journalism" -> R.string.category_journalism;
case "lgbt" -> R.string.category_lgbt;
case "regional" -> R.string.category_regional;
case "art" -> R.string.category_art;
case "music" -> R.string.category_music;
case "tech" -> R.string.category_tech;
default -> 0;
};
}
@Override
protected void updateFilteredList(){
ArrayList<CatalogInstance> prevData=new ArrayList<>(filteredData);
filteredData.clear();
if(searchQueryMode){
if(!TextUtils.isEmpty(currentSearchQuery)){
for(CatalogInstance instance:data){
if(instance.domain.contains(currentSearchQuery)){
filteredData.add(instance);
}
}
}
}else{
for(CatalogInstance instance:data){
if(categoryChoice==null || categoryChoice.matches(instance.category)){
if(chosenRegion==null || instance.region==chosenRegion){
boolean signupSpeedMatches=switch(currentSignupSpeedFilter){
case ANY -> true;
case INSTANT -> !instance.approvalRequired;
case REVIEWED -> instance.approvalRequired;
};
if(signupSpeedMatches){
if(currentLanguage==null || instance.languages.contains(currentLanguage)){
filteredData.add(instance);
}
}
}
}
}
}
DiffUtil.calculateDiff(new DiffUtil.Callback(){
@Override
public int getOldListSize(){
return prevData.size();
}
@Override
public int getNewListSize(){
return filteredData.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){
return prevData.get(oldItemPosition)==filteredData.get(newItemPosition);
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){
return prevData.get(oldItemPosition)==filteredData.get(newItemPosition);
}
}).dispatchUpdatesTo(adapter);
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
topBar.setPadding(0, insets.getSystemWindowInsetTop(), 0, 0);
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
}
@Override
public boolean onBackPressed(){
if(searchQueryMode){
setSearchQueryMode(false);
return true;
}
return false;
}
private void setSearchQueryMode(boolean enabled){
searchQueryMode=enabled;
RelativeLayout.LayoutParams lp=(RelativeLayout.LayoutParams) searchEdit.getLayoutParams();
if(searchQueryMode){
filtersScroll.setVisibility(View.GONE);
lp.removeRule(RelativeLayout.END_OF);
backBtn.setScaleX(0.83333333f);
backBtn.setScaleY(0.83333333f);
backBtn.setTranslationX(V.dp(8));
searchEdit.setCompoundDrawableTintList(ColorStateList.valueOf(0));
}else{
filtersScroll.setVisibility(View.VISIBLE);
focusThing.requestFocus();
searchEdit.setText("");
lp.addRule(RelativeLayout.END_OF, R.id.btn_back);
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(searchEdit.getWindowToken(), 0);
backBtn.setScaleX(1);
backBtn.setScaleY(1);
backBtn.setTranslationX(0);
searchEdit.setCompoundDrawableTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant)));
}
updateFilteredList();
}
private class InstancesAdapter extends UsableRecyclerView.Adapter<InstanceCatalogSignupFragment.InstanceViewHolder> implements ImageLoaderRecyclerAdapter{
public InstancesAdapter(){
super(imgLoader);
}
@NonNull
@Override
public InstanceCatalogSignupFragment.InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new InstanceCatalogSignupFragment.InstanceViewHolder();
}
@Override
public void onBindViewHolder(InstanceCatalogSignupFragment.InstanceViewHolder holder, int position){
holder.bind(filteredData.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return filteredData.size();
}
@Override
public int getItemViewType(int position){
return -1;
}
@Override
public int getImageCountForItem(int position){
return filteredData.get(position).thumbnailRequest!=null ? 1 : 0;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
return filteredData.get(position).thumbnailRequest;
}
}
private class InstanceViewHolder extends BindableViewHolder<CatalogInstance> implements UsableRecyclerView.DisableableClickable, ImageLoaderViewHolder{
private final TextView title, description;
private final RadioButton radioButton;
private final ImageView thumbnail;
private boolean enabled;
public InstanceViewHolder(){
super(getActivity(), R.layout.item_instance_catalog, list);
title=findViewById(R.id.title);
description=findViewById(R.id.description);
radioButton=findViewById(R.id.radiobtn);
thumbnail=findViewById(R.id.image);
}
@Override
public void onBind(CatalogInstance item){
title.setText(item.normalizedDomain);
radioButton.setChecked(chosenInstance==item);
if(item.thumbnailRequest==null)
thumbnail.setImageDrawable(null);
Instance realInstance=instancesCache.get(item.normalizedDomain);
float alpha;
if(realInstance!=null && !realInstance.registrations){
alpha=0.38f;
description.setText(R.string.not_accepting_new_members);
enabled=false;
}else{
alpha=1f;
description.setText(item.description);
enabled=true;
}
title.setAlpha(alpha);
description.setAlpha(alpha);
radioButton.setAlpha(alpha);
thumbnail.setAlpha(alpha);
}
@Override
public void onClick(){
if(chosenInstance==item)
return;
if(chosenInstance!=null){
int idx=filteredData.indexOf(chosenInstance);
if(idx!=-1){
boolean found=false;
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder.getAbsoluteAdapterPosition()==mergeAdapter.getPositionForAdapter(adapter)+idx && holder instanceof InstanceViewHolder ivh){
ivh.radioButton.setChecked(false);
found=true;
break;
}
}
if(!found)
adapter.notifyItemChanged(idx);
}
}
radioButton.setChecked(true);
if(chosenInstance==null)
nextButton.setEnabled(true);
chosenInstance=item;
loadInstanceInfo(chosenInstance.domain, false);
}
@Override
public void setImage(int index, Drawable image){
thumbnail.setImageDrawable(image);
}
@Override
public void clearImage(int index){
setImage(index, null);
}
@Override
public boolean isEnabled(){
return enabled;
}
}
private enum SignupSpeedFilter{
ANY,
INSTANT,
REVIEWED
}
private enum CategoryChoice{
GENERAL,
SPECIAL;
public boolean matches(String category){
boolean isGeneral=(category==null || "general".equals(category));
return (this==GENERAL)==isGeneral;
}
}
}

View File

@@ -0,0 +1,263 @@
package org.joinmastodon.android.fragments.onboarding;
import android.graphics.Canvas;
import android.graphics.Outline;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.widget.ImageButton;
import android.widget.RadioButton;
import android.widget.TextView;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.catalog.GetCatalogInstances;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.catalog.CatalogInstance;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
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.UsableRecyclerView;
public class InstanceChooserLoginFragment extends InstanceCatalogFragment{
private View headerView;
private boolean loadedAutocomplete;
private ImageButton clearBtn;
public InstanceChooserLoginFragment(){
super(R.layout.fragment_login, 10);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
dataLoaded();
setTitle(R.string.login_title);
if(!loadedAutocomplete){
loadAutocompleteServers();
}
}
@Override
protected void proceedWithAuthOrSignup(Instance instance){
AccountSessionManager.getInstance().authenticate(getActivity(), instance);
}
@Override
protected void updateFilteredList(){
ArrayList<CatalogInstance> prevData=new ArrayList<>(filteredData);
filteredData.clear();
if(currentSearchQuery.length()>0){
boolean foundExactMatch=false;
for(CatalogInstance inst:data){
if(inst.normalizedDomain.contains(currentSearchQuery)){
filteredData.add(inst);
if(inst.normalizedDomain.equals(currentSearchQuery))
foundExactMatch=true;
}
}
if(!foundExactMatch)
filteredData.add(0, fakeInstance);
}
UiUtils.updateList(prevData, filteredData, list, adapter, Objects::equals);
for(int i=0;i<list.getChildCount();i++){
list.getChildAt(i).invalidateOutline();
}
}
@Override
protected void doLoadData(int offset, int count){
}
private void loadAutocompleteServers(){
loadedAutocomplete=true;
new GetCatalogInstances(null, null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<CatalogInstance> result){
data.clear();
data.addAll(sortInstances(result));
}
@Override
public void onError(ErrorResponse error){
}
})
.execNoAuth("");
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
Toolbar toolbar=getToolbar();
toolbar.setElevation(0);
toolbar.setBackground(null);
}
@Override
protected RecyclerView.Adapter getAdapter(){
headerView=getActivity().getLayoutInflater().inflate(R.layout.header_onboarding_login, list, false);
clearBtn=headerView.findViewById(R.id.search_clear);
searchEdit=headerView.findViewById(R.id.search_edit);
searchEdit.setOnEditorActionListener(this::onSearchEnterPressed);
searchEdit.addTextChangedListener(new TextWatcher(){
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
searchEdit.removeCallbacks(searchDebouncer);
searchEdit.postDelayed(searchDebouncer, 300);
if(s.length()>0){
fakeInstance.domain=fakeInstance.normalizedDomain=s.toString();
fakeInstance.description=getString(R.string.loading_instance);
if(filteredData.size()>0 && filteredData.get(0)==fakeInstance){
if(list.findViewHolderForAdapterPosition(1) instanceof InstanceViewHolder ivh){
ivh.rebind();
}
}
if(filteredData.isEmpty()){
filteredData.add(fakeInstance);
adapter.notifyItemInserted(0);
}
clearBtn.setVisibility(View.VISIBLE);
}else{
clearBtn.setVisibility(View.GONE);
}
}
@Override
public void afterTextChanged(Editable s){
}
});
clearBtn.setOnClickListener(v->searchEdit.setText(""));
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView));
mergeAdapter.addAdapter(adapter=new InstancesAdapter());
return mergeAdapter;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if(parent.getChildViewHolder(view) instanceof InstanceViewHolder){
outRect.left=outRect.right=V.dp(16);
}
}
});
((UsableRecyclerView)list).setDrawSelectorOnTop(true);
}
private class InstancesAdapter extends UsableRecyclerView.Adapter<InstanceViewHolder>{
public InstancesAdapter(){
super(imgLoader);
}
@NonNull
@Override
public InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new InstanceViewHolder();
}
@Override
public void onBindViewHolder(InstanceViewHolder holder, int position){
holder.bind(filteredData.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return filteredData.size();
}
@Override
public int getItemViewType(int position){
return -1;
}
}
private class InstanceViewHolder extends BindableViewHolder<CatalogInstance> implements UsableRecyclerView.Clickable{
private final TextView title, description;
private final RadioButton radioButton;
public InstanceViewHolder(){
super(getActivity(), R.layout.item_instance_login, list);
title=findViewById(R.id.title);
description=findViewById(R.id.description);
radioButton=findViewById(R.id.radiobtn);
radioButton.setMinWidth(0);
radioButton.setMinHeight(0);
itemView.setOutlineProvider(new ViewOutlineProvider(){
@Override
public void getOutline(View view, Outline outline){
outline.setRoundRect(0, getAbsoluteAdapterPosition()==1 ? 0 : V.dp(-4), view.getWidth(), view.getHeight()+(getAbsoluteAdapterPosition()==filteredData.size() ? 0 : V.dp(4)), V.dp(4));
}
});
itemView.setClipToOutline(true);
}
@Override
public void onBind(CatalogInstance item){
title.setText(item.normalizedDomain);
description.setText(item.description);
radioButton.setChecked(chosenInstance==item);
}
@Override
public void onClick(){
if(chosenInstance==item)
return;
if(chosenInstance!=null){
int idx=filteredData.indexOf(chosenInstance);
if(idx!=-1){
boolean found=false;
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder.getAbsoluteAdapterPosition()==mergeAdapter.getPositionForAdapter(adapter)+idx && holder instanceof InstanceViewHolder ivh){
ivh.radioButton.setChecked(false);
found=true;
break;
}
}
if(!found)
adapter.notifyItemChanged(idx);
}
}
radioButton.setChecked(true);
if(chosenInstance==null)
nextButton.setEnabled(true);
chosenInstance=item;
loadInstanceInfo(chosenInstance.domain, false);
}
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments.onboarding;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.os.Build;
import android.os.Bundle;
@@ -14,6 +15,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
@@ -21,14 +23,14 @@ import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.fragments.ToolbarFragment;
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.UsableRecyclerView;
public class InstanceRulesFragment extends AppKitFragment{
public class InstanceRulesFragment extends ToolbarFragment{
private UsableRecyclerView list;
private MergeRecyclerAdapter adapter;
private Button btn;
@@ -46,31 +48,28 @@ public class InstanceRulesFragment extends AppKitFragment{
super.onAttach(activity);
setNavigationBarColor(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground));
instance=Parcels.unwrap(getArguments().getParcelable("instance"));
setTitle(R.string.instance_rules_title);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
View view=inflater.inflate(R.layout.fragment_onboarding_rules, container, false);
list=view.findViewById(R.id.list);
list.setLayoutManager(new LinearLayoutManager(getActivity()));
View headerView=inflater.inflate(R.layout.item_list_header, list, false);
TextView title=headerView.findViewById(R.id.title);
TextView subtitle=headerView.findViewById(R.id.subtitle);
headerView.findViewById(R.id.step_counter).setVisibility(View.GONE);
title.setText(R.string.instance_rules_title);
subtitle.setText(getString(R.string.instance_rules_subtitle, instance.uri));
View headerView=inflater.inflate(R.layout.item_list_header_simple, list, false);
TextView text=headerView.findViewById(R.id.text);
text.setText(getString(R.string.instance_rules_subtitle, instance.uri));
adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SingleViewRecyclerAdapter(headerView));
adapter.addAdapter(new ItemsAdapter());
list.setAdapter(adapter);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 16, 16, DividerItemDecoration.NOT_FIRST));
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3SurfaceVariant, 1, 56, 0, DividerItemDecoration.NOT_FIRST));
btn=view.findViewById(R.id.btn_next);
btn.setOnClickListener(v->onButtonClick());
buttonBar=view.findViewById(R.id.button_bar);
view.findViewById(R.id.btn_back).setOnClickListener(v->Nav.finish(this));
return view;
}
@@ -78,7 +77,15 @@ public class InstanceRulesFragment extends AppKitFragment{
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight));
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
getToolbar().setBackground(null);
getToolbar().setElevation(0);
}
protected void onButtonClick(){
@@ -118,20 +125,22 @@ public class InstanceRulesFragment extends AppKitFragment{
}
private class ItemViewHolder extends BindableViewHolder<Instance.Rule>{
private final TextView title, subtitle;
private final ImageView checkbox;
private final TextView text, number;
public ItemViewHolder(){
super(getActivity(), R.layout.item_report_choice, list);
title=findViewById(R.id.title);
subtitle=findViewById(R.id.subtitle);
checkbox=findViewById(R.id.checkbox);
subtitle.setVisibility(View.GONE);
super(getActivity(), R.layout.item_server_rule, list);
text=findViewById(R.id.text);
number=findViewById(R.id.number);
}
@SuppressLint("DefaultLocale")
@Override
public void onBind(Instance.Rule item){
title.setText(item.text);
if(item.parsedText==null){
item.parsedText=HtmlParser.parseLinks(item.text);
}
text.setText(item.parsedText);
number.setText(String.format("%d", getAbsoluteAdapterPosition()));
}
}
}

View File

@@ -25,14 +25,15 @@ import org.joinmastodon.android.api.MastodonDetailedErrorResponse;
import org.joinmastodon.android.api.requests.accounts.RegisterAccount;
import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp;
import org.joinmastodon.android.api.requests.oauth.GetOauthToken;
import org.joinmastodon.android.api.session.AccountActivationInfo;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Token;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout;
import org.parceler.Parcels;
import java.io.File;
@@ -49,30 +50,28 @@ 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.AppKitFragment;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class SignupFragment extends AppKitFragment{
public class SignupFragment extends ToolbarFragment{
private static final int AVATAR_RESULT=198;
private static final String TAG="SignupFragment";
private Instance instance;
private EditText displayName, username, email, password, reason;
private EditText displayName, username, email, password, passwordConfirm, reason;
private FloatingHintEditTextLayout displayNameWrap, usernameWrap, emailWrap, passwordWrap, passwordConfirmWrap, reasonWrap;
private TextView reasonExplain;
private Button btn;
private View buttonBar;
private TextWatcher buttonStateUpdater=new SimpleTextWatcher(e->updateButtonState());
private ImageView avatar;
private APIRequest currentBackgroundRequest;
private Application apiApplication;
private Token apiToken;
private boolean submitAfterGettingToken;
private ProgressDialog progressDialog;
private Uri avatarUri;
private File avatarFile;
private HashSet<EditText> errorFields=new HashSet<>();
@Override
@@ -81,25 +80,30 @@ public class SignupFragment extends AppKitFragment{
setRetainInstance(true);
instance=Parcels.unwrap(getArguments().getParcelable("instance"));
createAppAndGetToken();
setTitle(R.string.signup_title);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
public View onCreateContentView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
View view=inflater.inflate(R.layout.fragment_onboarding_signup, container, false);
TextView title=view.findViewById(R.id.title);
TextView domain=view.findViewById(R.id.domain);
displayName=view.findViewById(R.id.display_name);
username=view.findViewById(R.id.username);
email=view.findViewById(R.id.email);
password=view.findViewById(R.id.password);
avatar=view.findViewById(R.id.avatar);
passwordConfirm=view.findViewById(R.id.password_confirm);
reason=view.findViewById(R.id.reason);
reasonExplain=view.findViewById(R.id.reason_explain);
View avaWrap=view.findViewById(R.id.ava_wrap);
title.setText(getString(R.string.signup_title, instance.uri));
displayNameWrap=view.findViewById(R.id.display_name_wrap);
usernameWrap=view.findViewById(R.id.username_wrap);
emailWrap=view.findViewById(R.id.email_wrap);
passwordWrap=view.findViewById(R.id.password_wrap);
passwordConfirmWrap=view.findViewById(R.id.password_confirm_wrap);
reasonWrap=view.findViewById(R.id.reason_wrap);
domain.setText('@'+instance.uri);
username.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@@ -114,23 +118,20 @@ public class SignupFragment extends AppKitFragment{
btn=view.findViewById(R.id.btn_next);
btn.setOnClickListener(v->onButtonClick());
buttonBar=view.findViewById(R.id.button_bar);
view.findViewById(R.id.btn_back).setOnClickListener(v->Nav.finish(this));
updateButtonState();
username.addTextChangedListener(buttonStateUpdater);
email.addTextChangedListener(buttonStateUpdater);
password.addTextChangedListener(buttonStateUpdater);
passwordConfirm.addTextChangedListener(buttonStateUpdater);
reason.addTextChangedListener(buttonStateUpdater);
username.addTextChangedListener(new ErrorClearingListener(username));
email.addTextChangedListener(new ErrorClearingListener(email));
password.addTextChangedListener(new ErrorClearingListener(password));
passwordConfirm.addTextChangedListener(new ErrorClearingListener(passwordConfirm));
reason.addTextChangedListener(new ErrorClearingListener(reason));
avaWrap.setOutlineProvider(OutlineProviders.roundedRect(22));
avaWrap.setClipToOutline(true);
avaWrap.setOnClickListener(v->onAvatarClick());
if(!instance.approvalRequired){
reason.setVisibility(View.GONE);
reasonExplain.setVisibility(View.GONE);
@@ -142,10 +143,23 @@ public class SignupFragment extends AppKitFragment{
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight));
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
getToolbar().setBackground(null);
getToolbar().setElevation(0);
}
private void onButtonClick(){
if(!password.getText().toString().equals(passwordConfirm.getText().toString())){
passwordConfirm.setError(getString(R.string.signup_passwords_dont_match));
passwordConfirmWrap.setErrorState();
return;
}
showProgressDialog();
if(currentBackgroundRequest!=null){
submitAfterGettingToken=true;
@@ -160,32 +174,8 @@ public class SignupFragment extends AppKitFragment{
}
}
private void copyAvatar(Runnable onDone){
// Need to copy the avatar from the content provider to somewhere accessible in case the app gets killed between signup and account activation
Activity activity=getActivity();
MastodonAPIController.runInBackground(()->{
String origName=UiUtils.getFileName(avatarUri);
avatarFile=new File(activity.getCacheDir(), System.currentTimeMillis()+origName.substring(origName.lastIndexOf('.')));
try(InputStream in=activity.getContentResolver().openInputStream(avatarUri);
FileOutputStream out=new FileOutputStream(avatarFile)){
byte[] buf=new byte[10240];
int read;
while((read=in.read(buf))>0){
out.write(buf, 0, read);
}
}catch(IOException x){
Log.w(TAG, "copyAvatar: error copying", x);
}
activity.runOnUiThread(onDone);
});
}
private void submit(){
if(avatarUri!=null && (avatarFile==null || !avatarFile.exists())){
copyAvatar(this::actuallySubmit);
}else{
actuallySubmit();
}
actuallySubmit();
}
private void actuallySubmit(){
@@ -204,9 +194,7 @@ public class SignupFragment extends AppKitFragment{
fakeAccount.acct=fakeAccount.username=username;
fakeAccount.id="tmp"+System.currentTimeMillis();
fakeAccount.displayName=displayName.getText().toString();
if(avatarFile!=null)
fakeAccount.avatar=avatarFile.getAbsolutePath();
AccountSessionManager.getInstance().addAccount(instance, result, fakeAccount, apiApplication, false);
AccountSessionManager.getInstance().addAccount(instance, result, fakeAccount, apiApplication, new AccountActivationInfo(email, System.currentTimeMillis()));
Bundle args=new Bundle();
args.putString("account", AccountSessionManager.getInstance().getLastActiveAccountID());
Nav.goClearingStack(getActivity(), AccountActivationFragment.class, args);
@@ -225,6 +213,7 @@ public class SignupFragment extends AppKitFragment{
continue;
}
field.setError(fieldErrors.get(fieldName).stream().map(err->err.description).collect(Collectors.joining("\n")));
getFieldWrapByName(fieldName).setErrorState();
errorFields.add(field);
if(first){
first=false;
@@ -252,6 +241,16 @@ public class SignupFragment extends AppKitFragment{
};
}
private FloatingHintEditTextLayout getFieldWrapByName(String name){
return switch(name){
case "email" -> emailWrap;
case "username" -> usernameWrap;
case "password" -> passwordWrap;
case "reason" -> reasonWrap;
default -> null;
};
}
private void showProgressDialog(){
if(progressDialog==null){
progressDialog=new ProgressDialog(getActivity());
@@ -262,7 +261,7 @@ public class SignupFragment extends AppKitFragment{
}
private void updateButtonState(){
btn.setEnabled(username.length()>0 && email.length()>0 && email.getText().toString().contains("@") && password.length()>=8 && (!instance.approvalRequired || reason.length()>0));
btn.setEnabled(username.length()>0 && email.length()>0 && email.getText().toString().contains("@") && password.length()>=8 && passwordConfirm.length()>=8 && (!instance.approvalRequired || reason.length()>0));
}
private void createAppAndGetToken(){
@@ -324,20 +323,6 @@ public class SignupFragment extends AppKitFragment{
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data){
if(requestCode==AVATAR_RESULT && resultCode==Activity.RESULT_OK){
avatarUri=data.getData();
if(avatarFile!=null && avatarFile.exists())
avatarFile.delete();
ViewImageLoader.load(avatar, getResources().getDrawable(R.drawable.default_avatar), new UrlImageLoaderRequest(avatarUri, V.dp(100), V.dp(100)));
}
}
private void onAvatarClick(){
startActivityForResult(new Intent(Intent.ACTION_GET_CONTENT).setType("image/*").addCategory(Intent.CATEGORY_OPENABLE), AVATAR_RESULT);
}
private class ErrorClearingListener implements TextWatcher{
public final EditText editText;

View File

@@ -102,7 +102,7 @@ public class ReportCommentFragment extends MastodonToolbarFragment{
ReportReason reason=ReportReason.valueOf(getArguments().getString("reason"));
ArrayList<String> statusIDs=getArguments().getStringArrayList("statusIDs");
ArrayList<String> ruleIDs=getArguments().getStringArrayList("ruleIDs");
new SendReport(reportAccount.id, reason, statusIDs, ruleIDs, v.getId()==R.id.btn_back ? null : commentEdit.getText().toString(), false)
new SendReport(reportAccount.id, reason, statusIDs, ruleIDs, v.getId()==R.id.btn_back ? null : commentEdit.getText().toString(), true)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Object result){

View File

@@ -11,8 +11,10 @@ import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.fragments.MastodonToolbarFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Relationship;
@@ -130,6 +132,7 @@ public class ReportDoneFragment extends MastodonToolbarFragment{
@Override
public void onSuccess(Relationship result){
Nav.finish(ReportDoneFragment.this);
E.post(new RemoveAccountPostsEvent(accountID, reportAccount.id, true));
}
@Override

View File

@@ -50,6 +50,10 @@ public class Filter extends BaseModel{
return pattern.matcher(text).find();
}
public boolean matches(Status status){
return matches(status.getContentStatus().getStrippedText());
}
@Override
public String toString(){
return "Filter{"+

View File

@@ -134,6 +134,8 @@ public class Instance extends BaseModel{
public static class Rule{
public String id;
public String text;
public transient CharSequence parsedText;
}
@Parcel

View File

@@ -0,0 +1,21 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import java.time.Instant;
@AllFieldsAreRequired
public class Marker extends BaseModel{
public String lastReadId;
public long version;
public Instant updatedAt;
@Override
public String toString(){
return "Marker{"+
"lastReadId='"+lastReadId+'\''+
", version="+version+
", updatedAt="+updatedAt+
'}';
}
}

View File

@@ -13,7 +13,7 @@ public class Poll extends BaseModel{
@RequiredField
public String id;
public Instant expiresAt;
public boolean expired;
private boolean expired;
public boolean multiple;
public int votersCount;
public boolean voted;
@@ -48,6 +48,10 @@ public class Poll extends BaseModel{
'}';
}
public boolean isExpired(){
return expired || (expiresAt!=null && expiresAt.isBefore(Instant.now()));
}
@Parcel
public static class Option{
public String title;

View File

@@ -23,6 +23,7 @@ public class PushSubscription extends BaseModel implements Cloneable{
", endpoint='"+endpoint+'\''+
", alerts="+alerts+
", serverKey='"+serverKey+'\''+
", policy="+policy+
'}';
}

View File

@@ -3,6 +3,7 @@ package org.joinmastodon.android.model;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.parceler.Parcel;
import java.time.Instant;
@@ -56,6 +57,7 @@ public class Status extends BaseModel implements DisplayItemsParent{
public transient boolean spoilerRevealed;
public transient boolean hasGapAfter;
private transient String strippedText;
@Override
public void postprocess() throws ObjectValidationException{
@@ -127,9 +129,16 @@ public class Status extends BaseModel implements DisplayItemsParent{
repliesCount=ev.replies;
favourited=ev.favorited;
reblogged=ev.reblogged;
bookmarked=ev.bookmarked;
}
public Status getContentStatus(){
return reblog!=null ? reblog : this;
}
public String getStrippedText(){
if(strippedText==null)
strippedText=HtmlParser.strip(content);
return strippedText;
}
}

View File

@@ -1,5 +1,10 @@
package org.joinmastodon.android.model.catalog;
import android.graphics.Region;
import android.text.TextUtils;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.model.BaseModel;
@@ -7,13 +12,17 @@ import org.joinmastodon.android.model.BaseModel;
import java.net.IDN;
import java.util.List;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
@AllFieldsAreRequired
public class CatalogInstance extends BaseModel{
public String domain;
public String version;
public String description;
public List<String> languages;
public String region;
@SerializedName("region")
private String _region;
public List<String> categories;
public String proxiedThumbnail;
public int totalUsers;
@@ -22,7 +31,9 @@ public class CatalogInstance extends BaseModel{
public String language;
public String category;
public transient Region region;
public transient String normalizedDomain;
public transient UrlImageLoaderRequest thumbnailRequest;
@Override
public void postprocess() throws ObjectValidationException{
@@ -31,6 +42,14 @@ public class CatalogInstance extends BaseModel{
normalizedDomain=IDN.toUnicode(domain);
else
normalizedDomain=domain;
if(!TextUtils.isEmpty(_region)){
try{
region=Region.valueOf(_region.toUpperCase());
}catch(IllegalArgumentException ignore){}
}
if(!TextUtils.isEmpty(proxiedThumbnail)){
thumbnailRequest=new UrlImageLoaderRequest(proxiedThumbnail, 0, V.dp(56));
}
}
@Override
@@ -50,4 +69,13 @@ public class CatalogInstance extends BaseModel{
", category='"+category+'\''+
'}';
}
public enum Region{
EUROPE,
NORTH_AMERICA,
SOUTH_AMERICA,
AFRICA,
ASIA,
OCEANIA
}
}

View File

@@ -243,7 +243,10 @@ public class AccountSwitcherSheet extends BottomSheet{
public WrappedAccount(AccountSession session){
this.session=session;
req=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? session.self.avatar : session.self.avatarStatic, V.dp(50), V.dp(50));
if(session.self.avatar!=null)
req=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? session.self.avatar : session.self.avatarStatic, V.dp(50), V.dp(50));
else
req=null;
}
}
}

View File

@@ -28,6 +28,7 @@ import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -154,11 +155,16 @@ public class ComposeAutocompleteViewController{
}else if(mode==Mode.EMOJIS){
String _text=text.substring(1); // remove ':'
List<WrappedEmoji> oldList=emojis;
emojis=AccountSessionManager.getInstance()
List<Emoji> allEmojis = AccountSessionManager.getInstance()
.getCustomEmojis(AccountSessionManager.getInstance().getAccount(accountID).domain)
.stream()
.flatMap(ec->ec.emojis.stream())
.filter(e->e.visibleInPicker && e.shortcode.startsWith(_text))
.filter(e->e.visibleInPicker)
.collect(Collectors.toList());
List<Emoji> startsWithSearch = allEmojis.stream().filter(e -> e.shortcode.toLowerCase().startsWith(_text.toLowerCase())).collect(Collectors.toList());
emojis=Stream.concat(startsWithSearch.stream(), allEmojis.stream()
.filter(e -> !startsWithSearch.contains(e))
.filter(e -> e.shortcode.toLowerCase().contains(_text.toLowerCase())))
.map(WrappedEmoji::new)
.collect(Collectors.toList());
UiUtils.updateList(oldList, emojis, list, emojisAdapter, (e1, e2)->e1.emoji.shortcode.equals(e2.emoji.shortcode));

View File

@@ -93,7 +93,7 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
private void bindButton(TextView btn, long count){
if(count>0 && !item.hideCounts){
btn.setText(DecimalFormat.getIntegerInstance().format(count));
btn.setText(UiUtils.abbreviateNumber(count));
btn.setCompoundDrawablePadding(V.dp(8));
}else{
btn.setText("");

View File

@@ -5,6 +5,7 @@ import android.app.ProgressDialog;
import android.graphics.Outline;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
@@ -192,6 +193,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
});
}else if(id==R.id.block_domain){
UiUtils.confirmToggleBlockDomain(activity, item.parentFragment.getAccountID(), account.getDomain(), relationship!=null && relationship.domainBlocking, ()->{});
}else if(id==R.id.bookmark){
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(item.status, !item.status.bookmarked);
}
return true;
});
@@ -209,6 +212,9 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
if(item.hasVisibilityToggle){
visibility.setImageResource(item.status.spoilerRevealed ? R.drawable.ic_visibility_off : R.drawable.ic_visibility);
visibility.setContentDescription(item.parentFragment.getString(item.status.spoilerRevealed ? R.string.hide_content : R.string.reveal_content));
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){
visibility.setTooltipText(visibility.getContentDescription());
}
}
itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), item.needBottomPadding ? V.dp(16) : 0);
if(TextUtils.isEmpty(item.extraText)){
@@ -286,6 +292,13 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
MenuItem block=menu.findItem(R.id.block);
MenuItem report=menu.findItem(R.id.report);
MenuItem follow=menu.findItem(R.id.follow);
MenuItem bookmark=menu.findItem(R.id.bookmark);
if(item.status!=null){
bookmark.setVisible(true);
bookmark.setTitle(item.status.bookmarked ? R.string.remove_bookmark : R.string.add_bookmark);
}else{
bookmark.setVisible(false);
}
if(isOwnPost){
mute.setVisible(false);
block.setVisible(false);

View File

@@ -85,6 +85,7 @@ public abstract class ImageStatusDisplayItem extends StatusDisplayItem{
@Override
public void clearImage(int index){
crossfadeDrawable.setCrossfadeAlpha(1f);
crossfadeDrawable.setImageDrawable(null);
didClear=true;
}

View File

@@ -1,7 +1,18 @@
package org.joinmastodon.android.ui.displayitems;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ScrollView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
@@ -10,6 +21,7 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.CubicBezierInterpolator;
public class PhotoStatusDisplayItem extends ImageStatusDisplayItem{
public PhotoStatusDisplayItem(String parentID, Status status, Attachment photo, BaseStatusListFragment parentFragment, int index, int totalPhotos, PhotoLayoutHelper.TiledLayoutResult tiledLayout, PhotoLayoutHelper.TiledLayoutResult.Tile thisTile){
@@ -23,9 +35,109 @@ public class PhotoStatusDisplayItem extends ImageStatusDisplayItem{
}
public static class Holder extends ImageStatusDisplayItem.Holder<PhotoStatusDisplayItem>{
private final FrameLayout altTextWrapper;
private final TextView altTextButton;
private final View altTextScroller;
private final ImageButton altTextClose;
private final TextView altText;
private boolean altTextShown;
private AnimatorSet currentAnim;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_photo, parent);
altTextWrapper=findViewById(R.id.alt_text_wrapper);
altTextButton=findViewById(R.id.alt_button);
altTextScroller=findViewById(R.id.alt_text_scroller);
altTextClose=findViewById(R.id.alt_text_close);
altText=findViewById(R.id.alt_text);
altTextButton.setOnClickListener(this::onShowHideClick);
altTextClose.setOnClickListener(this::onShowHideClick);
// altTextScroller.setNestedScrollingEnabled(true);
}
@Override
public void onBind(ImageStatusDisplayItem item){
super.onBind(item);
altTextShown=false;
if(currentAnim!=null)
currentAnim.cancel();
altTextScroller.setVisibility(View.GONE);
altTextClose.setVisibility(View.GONE);
altTextButton.setVisibility(View.VISIBLE);
if(TextUtils.isEmpty(item.attachment.description)){
altTextWrapper.setVisibility(View.GONE);
}else{
altTextWrapper.setVisibility(View.VISIBLE);
altText.setText(item.attachment.description);
}
}
private void onShowHideClick(View v){
boolean show=v.getId()==R.id.alt_button;
if(altTextShown==show)
return;
if(currentAnim!=null)
currentAnim.cancel();
altTextShown=show;
if(show){
altTextScroller.setVisibility(View.VISIBLE);
altTextClose.setVisibility(View.VISIBLE);
}else{
altTextButton.setVisibility(View.VISIBLE);
// Hide these views temporarily so FrameLayout measures correctly
altTextScroller.setVisibility(View.GONE);
altTextClose.setVisibility(View.GONE);
}
// This is the current size...
int prevLeft=altTextWrapper.getLeft();
int prevRight=altTextWrapper.getRight();
int prevTop=altTextWrapper.getTop();
altTextWrapper.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
altTextWrapper.getViewTreeObserver().removeOnPreDrawListener(this);
// ...and this is after the layout pass, right now the FrameLayout has its final size, but we animate that change
if(!show){
// Show these views again so they're visible for the duration of the animation.
// No one would notice they were missing during measure/layout.
altTextScroller.setVisibility(View.VISIBLE);
altTextClose.setVisibility(View.VISIBLE);
}
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofInt(altTextWrapper, "left", prevLeft, altTextWrapper.getLeft()),
ObjectAnimator.ofInt(altTextWrapper, "right", prevRight, altTextWrapper.getRight()),
ObjectAnimator.ofInt(altTextWrapper, "top", prevTop, altTextWrapper.getTop()),
ObjectAnimator.ofFloat(altTextButton, View.ALPHA, show ? 1f : 0f, show ? 0f : 1f),
ObjectAnimator.ofFloat(altTextScroller, View.ALPHA, show ? 0f : 1f, show ? 1f : 0f),
ObjectAnimator.ofFloat(altTextClose, View.ALPHA, show ? 0f : 1f, show ? 1f : 0f)
);
set.setDuration(300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
if(show){
altTextButton.setVisibility(View.GONE);
}else{
altTextScroller.setVisibility(View.GONE);
altTextClose.setVisibility(View.GONE);
}
currentAnim=null;
}
});
set.start();
currentAnim=set;
return true;
}
});
}
}
}

View File

@@ -38,13 +38,13 @@ public class PollFooterStatusDisplayItem extends StatusDisplayItem{
@Override
public void onBind(PollFooterStatusDisplayItem item){
String text=item.parentFragment.getResources().getQuantityString(R.plurals.x_voters, item.poll.votersCount, item.poll.votersCount);
if(item.poll.expiresAt!=null && !item.poll.expired){
if(item.poll.expiresAt!=null && !item.poll.isExpired()){
text+=" · "+UiUtils.formatTimeLeft(itemView.getContext(), item.poll.expiresAt);
}else if(item.poll.expired){
}else if(item.poll.isExpired()){
text+=" · "+item.parentFragment.getString(R.string.poll_closed);
}
this.text.setText(text);
button.setVisibility(item.poll.expired || item.poll.voted || !item.poll.multiple ? View.GONE : View.VISIBLE);
button.setVisibility(item.poll.isExpired() || item.poll.voted || !item.poll.multiple ? View.GONE : View.VISIBLE);
button.setEnabled(item.poll.selectedOptions!=null && !item.poll.selectedOptions.isEmpty());
}
}

View File

@@ -33,7 +33,7 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
this.poll=poll;
text=HtmlParser.parseCustomEmoji(option.title, poll.emojis);
emojiHelper.setText(text);
showResults=poll.expired || poll.voted;
showResults=poll.isExpired() || poll.voted;
if(showResults && option.votesCount!=null && poll.votersCount>0){
votesFraction=(float)option.votesCount/(float)poll.votersCount;
int mostVotedCount=0;

View File

@@ -2,8 +2,11 @@ package org.joinmastodon.android.ui.text;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.widget.TextView;
import com.twitter.twittertext.Regex;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Mention;
@@ -28,6 +31,21 @@ import androidx.annotation.NonNull;
public class HtmlParser{
private static final String TAG="HtmlParser";
private static final String VALID_URL_PATTERN_STRING =
"(" + // $1 total match
"(" + Regex.URL_VALID_PRECEDING_CHARS + ")" + // $2 Preceding character
"(" + // $3 URL
"(https?://)" + // $4 Protocol (optional)
"(" + Regex.URL_VALID_DOMAIN + ")" + // $5 Domain(s)
"(?::(" + Regex.URL_VALID_PORT_NUMBER + "))?" + // $6 Port number (optional)
"(/" +
Regex.URL_VALID_PATH + "*+" +
")?" + // $7 URL Path and anchor
"(\\?" + Regex.URL_VALID_URL_QUERY_CHARS + "*" + // $8 Query String
Regex.URL_VALID_URL_QUERY_ENDING_CHARS + ")?" +
")" +
")";
public static final Pattern URL_PATTERN=Pattern.compile(VALID_URL_PATTERN_STRING, Pattern.CASE_INSENSITIVE);
private static Pattern EMOJI_CODE_PATTERN=Pattern.compile(":([\\w]+):");
private HtmlParser(){}
@@ -172,4 +190,18 @@ public class HtmlParser{
public static String strip(String html){
return Jsoup.clean(html, Safelist.none());
}
public static CharSequence parseLinks(String text){
Matcher matcher=URL_PATTERN.matcher(text);
if(!matcher.find()) // Return the original string if there are no URLs
return text;
SpannableStringBuilder ssb=new SpannableStringBuilder(text);
do{
String url=matcher.group(3);
if(TextUtils.isEmpty(matcher.group(4)))
url="http://"+url;
ssb.setSpan(new LinkSpan(url, null, LinkSpan.Type.URL, null), matcher.start(3), matcher.end(3), 0);
}while(matcher.find()); // Find more URLs
return ssb;
}
}

View File

@@ -23,6 +23,7 @@ import android.os.Looper;
import android.provider.OpenableColumns;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
@@ -43,6 +44,7 @@ import org.joinmastodon.android.api.requests.accounts.SetDomainBlocked;
import org.joinmastodon.android.api.requests.statuses.DeleteStatus;
import org.joinmastodon.android.api.requests.statuses.GetStatusByID;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.events.StatusDeletedEvent;
import org.joinmastodon.android.fragments.HashtagTimelineFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
@@ -325,6 +327,9 @@ public class UiUtils{
@Override
public void onSuccess(Relationship result){
resultCallback.accept(result);
if(!currentlyBlocked){
E.post(new RemoveAccountPostsEvent(accountID, account.id, false));
}
}
@Override
@@ -367,6 +372,9 @@ public class UiUtils{
@Override
public void onSuccess(Relationship result){
resultCallback.accept(result);
if(!currentlyMuted){
E.post(new RemoveAccountPostsEvent(accountID, account.id, false));
}
}
@Override
@@ -448,6 +456,9 @@ public class UiUtils{
public void onSuccess(Relationship result){
resultCallback.accept(result);
progressCallback.accept(false);
if(!result.following){
E.post(new RemoveAccountPostsEvent(accountID, account.id, true));
}
}
@Override
@@ -550,8 +561,7 @@ public class UiUtils{
public static void openURL(Context context, String accountID, String url){
Uri uri=Uri.parse(url);
String accountDomain=AccountSessionManager.getInstance().getAccount(accountID).domain;
if("https".equals(uri.getScheme()) && accountDomain.equalsIgnoreCase(uri.getAuthority())){
if(accountID!=null && "https".equals(uri.getScheme()) && AccountSessionManager.getInstance().getAccount(accountID).domain.equalsIgnoreCase(uri.getAuthority())){
List<String> path=uri.getPathSegments();
// Match URLs like https://mastodon.social/@Gargron/108132679274083591
if(path.size()==2 && path.get(0).matches("^@[a-zA-Z0-9_]+$") && path.get(1).matches("^[0-9]+$")){
@@ -578,4 +588,17 @@ public class UiUtils{
}
launchWebBrowser(context, url);
}
private static String getSystemProperty(String key){
try{
Class<?> props=Class.forName("android.os.SystemProperties");
Method get=props.getMethod("get", String.class);
return (String)get.invoke(null, key);
}catch(Exception ignore){}
return null;
}
public static boolean isMIUI(){
return !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.code"));
}
}

View File

@@ -8,7 +8,6 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.AttributeSet;
import android.util.Log;
import android.view.DragEvent;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
@@ -22,7 +21,6 @@ import androidx.annotation.RequiresApi;
public class ComposeEditText extends EditText{
private SelectionListener selectionListener;
private MediaAcceptingInputConnection inputConnectionWrapper=new MediaAcceptingInputConnection();
public ComposeEditText(Context context){
super(context);
@@ -54,11 +52,10 @@ public class ComposeEditText extends EditText{
// Support receiving images from keyboards
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs){
final var ic = super.onCreateInputConnection(outAttrs);
final InputConnection ic=super.onCreateInputConnection(outAttrs);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N_MR1){
outAttrs.contentMimeTypes=selectionListener.onGetAllowedMediaMimeTypes();
inputConnectionWrapper.setTarget(ic);
return inputConnectionWrapper;
return new MediaAcceptingInputConnection(ic);
}
return ic;
}
@@ -106,8 +103,8 @@ public class ComposeEditText extends EditText{
}
private class MediaAcceptingInputConnection extends InputConnectionWrapper{
public MediaAcceptingInputConnection(){
super(null, true);
public MediaAcceptingInputConnection(InputConnection conn){
super(conn, false);
}
@RequiresApi(api=Build.VERSION_CODES.N_MR1)

View File

@@ -0,0 +1,59 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.Button;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.DrawableRes;
import me.grishka.appkit.utils.V;
public class FilterChipView extends Button{
private boolean currentlySelected;
public FilterChipView(Context context){
this(context, null);
}
public FilterChipView(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public FilterChipView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
setCompoundDrawablePadding(V.dp(8));
setBackgroundResource(R.drawable.bg_filter_chip);
setTextAppearance(R.style.m3_label_large);
setTextColor(getResources().getColorStateList(R.color.filter_chip_text, context.getTheme()));
setCompoundDrawableTintList(ColorStateList.valueOf(UiUtils.getThemeColor(context, R.attr.colorM3OnSurface)));
updatePadding();
}
@Override
protected void drawableStateChanged(){
super.drawableStateChanged();
if(currentlySelected==isSelected())
return;
currentlySelected=isSelected();
Drawable start=currentlySelected ? getResources().getDrawable(R.drawable.ic_baseline_check_18, getContext().getTheme()) : null;
Drawable end=getCompoundDrawablesRelative()[2];
setCompoundDrawablesRelativeWithIntrinsicBounds(start, null, end, null);
updatePadding();
}
private void updatePadding(){
int vertical=V.dp(6);
Drawable[] drawables=getCompoundDrawablesRelative();
setPaddingRelative(V.dp(drawables[0]==null ? 16 : 8), vertical, V.dp(drawables[2]==null ? 16 : 8), vertical);
}
public void setDrawableEnd(@DrawableRes int drawable){
setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, drawable, 0);
updatePadding();
}
}

View File

@@ -0,0 +1,320 @@
package org.joinmastodon.android.ui.views;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.InsetDrawable;
import android.os.Build;
import android.text.Editable;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class FloatingHintEditTextLayout extends FrameLayout{
private EditText edit;
private TextView label;
private int labelTextSize;
private int offsetY;
private boolean hintVisible;
private Animator currentAnim;
private float animProgress;
private RectF tmpRect=new RectF();
private ColorStateList labelColors, origHintColors;
private boolean errorState;
public FloatingHintEditTextLayout(Context context){
this(context, null);
}
public FloatingHintEditTextLayout(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public FloatingHintEditTextLayout(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
if(isInEditMode())
V.setApplicationContext(context);
TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.FloatingHintEditTextLayout);
labelTextSize=ta.getDimensionPixelSize(R.styleable.FloatingHintEditTextLayout_android_labelTextSize, V.dp(12));
offsetY=ta.getDimensionPixelOffset(R.styleable.FloatingHintEditTextLayout_editTextOffsetY, 0);
labelColors=ta.getColorStateList(R.styleable.FloatingHintEditTextLayout_labelTextColor);
ta.recycle();
setAddStatesFromChildren(true);
}
@Override
protected void onFinishInflate(){
super.onFinishInflate();
if(getChildCount()>0 && getChildAt(0) instanceof EditText et){
edit=et;
}else{
throw new IllegalStateException("First child must be an EditText");
}
label=new TextView(getContext());
label.setTextSize(TypedValue.COMPLEX_UNIT_PX, labelTextSize);
// label.setTextColor(labelColors==null ? edit.getHintTextColors() : labelColors);
origHintColors=edit.getHintTextColors();
label.setText(edit.getHint());
label.setSingleLine();
label.setPivotX(0f);
label.setPivotY(0f);
label.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
LayoutParams lp=new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.START | Gravity.TOP);
lp.setMarginStart(edit.getPaddingStart()+((LayoutParams)edit.getLayoutParams()).getMarginStart());
addView(label, lp);
hintVisible=edit.getText().length()==0;
if(hintVisible)
label.setAlpha(0f);
edit.addTextChangedListener(new SimpleTextWatcher(this::onTextChanged));
}
private void onTextChanged(Editable text){
if(errorState){
errorState=false;
setForeground(getResources().getDrawable(R.drawable.bg_m3_outlined_text_field));
refreshDrawableState();
}
boolean newHintVisible=text.length()==0;
if(newHintVisible==hintVisible)
return;
if(currentAnim!=null)
currentAnim.cancel();
hintVisible=newHintVisible;
label.setAlpha(1);
edit.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
edit.getViewTreeObserver().removeOnPreDrawListener(this);
float scale=edit.getLineHeight()/(float)label.getLineHeight();
float transY=edit.getHeight()/2f-edit.getLineHeight()/2f+(edit.getTop()-label.getTop())-(label.getHeight()/2f-label.getLineHeight()/2f);
AnimatorSet anim=new AnimatorSet();
if(hintVisible){
anim.playTogether(
ObjectAnimator.ofFloat(edit, TRANSLATION_Y, 0),
ObjectAnimator.ofFloat(label, SCALE_X, scale),
ObjectAnimator.ofFloat(label, SCALE_Y, scale),
ObjectAnimator.ofFloat(label, TRANSLATION_Y, transY),
ObjectAnimator.ofFloat(FloatingHintEditTextLayout.this, "animProgress", 0f)
);
edit.setHintTextColor(0);
}else{
label.setScaleX(scale);
label.setScaleY(scale);
label.setTranslationY(transY);
anim.playTogether(
ObjectAnimator.ofFloat(edit, TRANSLATION_Y, offsetY),
ObjectAnimator.ofFloat(label, SCALE_X, 1f),
ObjectAnimator.ofFloat(label, SCALE_Y, 1f),
ObjectAnimator.ofFloat(label, TRANSLATION_Y, 0f),
ObjectAnimator.ofFloat(FloatingHintEditTextLayout.this, "animProgress", 1f)
);
}
anim.setDuration(150);
anim.setInterpolator(CubicBezierInterpolator.DEFAULT);
anim.start();
anim.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
currentAnim=null;
if(hintVisible){
label.setAlpha(0);
edit.setHintTextColor(origHintColors);
}
}
});
currentAnim=anim;
return true;
}
});
}
@Keep
public void setAnimProgress(float progress){
animProgress=progress;
invalidate();
}
@Keep
public float getAnimProgress(){
return animProgress;
}
@Override
public void onDrawForeground(Canvas canvas){
if(getForeground()!=null && animProgress>0){
canvas.save();
float width=(label.getWidth()+V.dp(8))*animProgress;
float centerX=label.getLeft()+label.getWidth()/2f;
tmpRect.set(centerX-width/2f, label.getTop(), centerX+width/2f, label.getBottom());
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O)
canvas.clipOutRect(tmpRect);
else
canvas.clipRect(tmpRect, Region.Op.DIFFERENCE);
super.onDrawForeground(canvas);
canvas.restore();
}else{
super.onDrawForeground(canvas);
}
}
@Override
public void setForeground(Drawable foreground){
super.setForeground(new PaddedForegroundDrawable(foreground));
}
@Override
public Drawable getForeground(){
if(super.getForeground() instanceof PaddedForegroundDrawable pfd){
return pfd.wrapped;
}
return null;
}
@Override
protected void drawableStateChanged(){
super.drawableStateChanged();
if(label==null || errorState)
return;
ColorStateList color=labelColors==null ? origHintColors : labelColors;
label.setTextColor(color.getColorForState(getDrawableState(), 0xff00ff00));
}
public void setErrorState(){
if(errorState)
return;
errorState=true;
setForeground(getResources().getDrawable(R.drawable.bg_m3_outlined_text_field_error, getContext().getTheme()));
label.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Error));
}
private class PaddedForegroundDrawable extends Drawable{
private final Drawable wrapped;
private PaddedForegroundDrawable(Drawable wrapped){
this.wrapped=wrapped;
wrapped.setCallback(new Callback(){
@Override
public void invalidateDrawable(@NonNull Drawable who){
invalidateSelf();
}
@Override
public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when){
scheduleSelf(what, when);
}
@Override
public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what){
unscheduleSelf(what);
}
});
}
@Override
public void draw(@NonNull Canvas canvas){
wrapped.draw(canvas);
}
@Override
public void setAlpha(int alpha){
wrapped.setAlpha(alpha);
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
wrapped.setColorFilter(colorFilter);
}
@Override
public int getOpacity(){
return wrapped.getOpacity();
}
@Override
public boolean setState(@NonNull int[] stateSet){
return wrapped.setState(stateSet);
}
@Override
public int getLayoutDirection(){
return wrapped.getLayoutDirection();
}
@Override
public int getAlpha(){
return wrapped.getAlpha();
}
@Nullable
@Override
public ColorFilter getColorFilter(){
return wrapped.getColorFilter();
}
@Override
public boolean isStateful(){
return wrapped.isStateful();
}
@NonNull
@Override
public int[] getState(){
return wrapped.getState();
}
@NonNull
@Override
public Drawable getCurrent(){
return wrapped.getCurrent();
}
@Override
public void applyTheme(@NonNull Resources.Theme t){
wrapped.applyTheme(t);
}
@Override
public boolean canApplyTheme(){
return wrapped.canApplyTheme();
}
@Override
protected void onBoundsChange(@NonNull Rect bounds){
super.onBoundsChange(bounds);
LayoutParams lp=(LayoutParams) edit.getLayoutParams();
wrapped.setBounds(bounds.left+lp.leftMargin-V.dp(12), bounds.top, bounds.right-lp.rightMargin+V.dp(12), bounds.bottom);
}
}
}

View File

@@ -0,0 +1,58 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import android.widget.ScrollView;
public class NestableScrollView extends ScrollView{
private float downY, touchslop;
private boolean didDisallow;
public NestableScrollView(Context context){
super(context);
init();
}
public NestableScrollView(Context context, AttributeSet attrs){
super(context, attrs);
init();
}
public NestableScrollView(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
init();
}
public NestableScrollView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes){
super(context, attrs, defStyleAttr, defStyleRes);
init();
}
private void init(){
touchslop=ViewConfiguration.get(getContext()).getScaledTouchSlop();
}
@Override
public boolean onTouchEvent(MotionEvent ev){
if(ev.getAction()==MotionEvent.ACTION_DOWN){
if(canScrollVertically(-1) || canScrollVertically(1)){
getParent().requestDisallowInterceptTouchEvent(true);
didDisallow=true;
}else{
didDisallow=false;
}
downY=ev.getY();
}else if(didDisallow && ev.getAction()==MotionEvent.ACTION_MOVE){
if(Math.abs(downY-ev.getY())>=touchslop){
if(!canScrollVertically((int)(downY-ev.getY()))){
didDisallow=false;
getParent().requestDisallowInterceptTouchEvent(false);
}
}
}
return super.onTouchEvent(ev);
}
}

View File

@@ -0,0 +1,70 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.ImageView;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class SplashLogoView extends ImageView{
private Bitmap shadow;
private Paint paint=new Paint();
public SplashLogoView(Context context){
this(context, null);
}
public SplashLogoView(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public SplashLogoView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
protected void onDraw(Canvas canvas){
if(shadow!=null){
paint.setColor(0xBF000000);
canvas.drawBitmap(shadow, 0, 0, paint);
}
super.onDraw(canvas);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh){
super.onSizeChanged(w, h, oldw, oldh);
if(w!=oldw || h!=oldh)
updateShadow();
}
@Override
public void setImageDrawable(@Nullable Drawable drawable){
super.setImageDrawable(drawable);
updateShadow();
}
private void updateShadow(){
int w=getWidth();
int h=getHeight();
Drawable drawable=getDrawable();
if(w==0 || h==0 || drawable==null)
return;
drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
Bitmap temp=Bitmap.createBitmap(w, h, Bitmap.Config.ALPHA_8);
shadow=Bitmap.createBitmap(w, h, Bitmap.Config.ALPHA_8);
Canvas c=new Canvas(temp);
c.translate(getWidth()/2f-drawable.getIntrinsicWidth()/2f, getHeight()/2f-drawable.getIntrinsicHeight()/2f);
drawable.draw(c);
c=new Canvas(shadow);
Paint paint=new Paint();
paint.setShadowLayer(V.dp(2), 0, 0, 0xff000000);
c.drawBitmap(temp, 0, 0, paint);
}
}

View File

@@ -21,9 +21,8 @@ public class StatusFilterPredicate implements Predicate<Status>{
@Override
public boolean test(Status status){
CharSequence content=status.getContentStatus().content;
for(Filter filter:filters){
if(filter.matches(content))
if(filter.matches(status))
return false;
}
return true;

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3OnPrimary" android:state_enabled="true"/>
<item android:color="?colorM3OnSurface" android:alpha="0.38"/>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3Primary" android:state_enabled="true"/>
<item android:color="?colorM3OnSurface" android:alpha="0.38"/>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3OnSecondaryContainer" android:state_selected="true"/>
<item android:color="?colorM3OnSurface"/>
</selector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3OnSecondaryContainer" android:alpha="0.12"/>
</selector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3OnSurfaceVariant" android:alpha="0.12"/>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3Primary" android:state_focused="true"/>
<item android:color="?colorM3OnSurfaceVariant"/>
</selector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3PressedOverlay" android:alpha="0.12"/>
</selector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3Primary" android:alpha="0.12"/>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3Primary" android:state_checked="true"/>
<item android:color="?colorM3OnSurfaceVariant"/>
</selector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="true">
<ripple android:color="@color/m3_pressed_overlay">
<item>
<shape>
<solid android:color="?colorM3Primary"/>
<corners android:radius="20dp"/>
</shape>
</item>
</ripple>
</item>
<item>
<shape>
<solid android:color="?colorM3DisabledBackground"/>
<corners android:radius="20dp"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple android:color="@color/m3_primary_overlay" xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/mask">
<shape>
<solid android:color="#000"/>
<corners android:radius="20dp"/>
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<ripple android:color="@color/m3_on_secondary_container_overlay">
<item>
<shape>
<corners android:radius="8dp"/>
<solid android:color="?colorM3SecondaryContainer"/>
</shape>
</item>
</ripple>
</item>
<item>
<ripple android:color="@color/m3_on_surface_variant_overlay">
<item android:id="@android:id/mask">
<shape>
<corners android:radius="8dp"/>
<solid android:color="#000"/>
</shape>
</item>
<item>
<shape>
<corners android:radius="8dp"/>
<stroke android:color="?colorM3Outline" android:width="1dp"/>
</shape>
</item>
</ripple>
</item>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#D9000000"/>
<corners android:radius="4dp"/>
</shape>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:left="12dp" android:top="12dp" android:right="12dp" android:bottom="12dp">
<selector>
<item android:state_focused="true">
<shape>
<stroke android:color="?colorM3Primary" android:width="2dp"/>
<corners android:radius="4dp"/>
</shape>
</item>
<item>
<shape>
<stroke android:color="?colorM3Outline" android:width="1dp"/>
<corners android:radius="4dp"/>
</shape>
</item>
</selector>
</item>
</layer-list>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:left="12dp" android:top="12dp" android:right="12dp" android:bottom="12dp">
<shape>
<stroke android:color="?colorM3Error" android:width="2dp"/>
<corners android:radius="4dp"/>
</shape>
</item>
</layer-list>

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