Compare commits
476 Commits
v1.1.3+for
...
v1.1.4+for
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e840c2584 | ||
|
|
adf628de44 | ||
|
|
7e4169644d | ||
|
|
e42988b51e | ||
|
|
7c2fd2b734 | ||
|
|
569e4e13ea | ||
|
|
b7b5785a97 | ||
|
|
95d6e51ae0 | ||
|
|
fc43d919e5 | ||
|
|
a8d19529cd | ||
|
|
c2c44ab25e | ||
|
|
96a56873c3 | ||
|
|
0100967597 | ||
|
|
5769b378e2 | ||
|
|
2c61551e5c | ||
|
|
c9b11a23a1 | ||
|
|
456d05a489 | ||
|
|
8ca7d9d7b4 | ||
|
|
2321549dca | ||
|
|
5ae96328d5 | ||
|
|
dbf3da3629 | ||
|
|
9a351de9b4 | ||
|
|
f871fa6743 | ||
|
|
85f6441a56 | ||
|
|
dd8354f91b | ||
|
|
21d78de3cc | ||
|
|
158af27309 | ||
|
|
b0446af54a | ||
|
|
064f0155f6 | ||
|
|
fbf4522260 | ||
|
|
8918bd5ce2 | ||
|
|
187693883c | ||
|
|
40868f90f9 | ||
|
|
1f89acec34 | ||
|
|
d75ce99a68 | ||
|
|
0a8846fa2a | ||
|
|
5253e2e24a | ||
|
|
3565223611 | ||
|
|
2343d7a046 | ||
|
|
8256fbadb6 | ||
|
|
b898dc010e | ||
|
|
de369633ec | ||
|
|
3f075eab13 | ||
|
|
96e5f854a5 | ||
|
|
0f5211f718 | ||
|
|
480915f377 | ||
|
|
15d559ad6a | ||
|
|
3b7a6e9385 | ||
|
|
314a15973c | ||
|
|
e99917945a | ||
|
|
d3ba8a4d0f | ||
|
|
9162b31ac1 | ||
|
|
51a80f3e03 | ||
|
|
a8c49b59f6 | ||
|
|
c79942c13f | ||
|
|
da121495c0 | ||
|
|
2d710cb558 | ||
|
|
f0a51a15a9 | ||
|
|
ae68b1e646 | ||
|
|
5da58d7834 | ||
|
|
24023e9843 | ||
|
|
29d9871e77 | ||
|
|
32d182f03a | ||
|
|
da4f54751e | ||
|
|
393c538464 | ||
|
|
ddcf61dc95 | ||
|
|
5b70c035d2 | ||
|
|
289564381d | ||
|
|
3fbcce8570 | ||
|
|
505b755df6 | ||
|
|
4b9618cad5 | ||
|
|
e7b0e022d6 | ||
|
|
7ffc58d52c | ||
|
|
30151cafc2 | ||
|
|
d0ce157069 | ||
|
|
be7d65989d | ||
|
|
08656a2678 | ||
|
|
b0f76739ba | ||
|
|
a09efd084e | ||
|
|
0bb85d71e4 | ||
|
|
7248ab9801 | ||
|
|
cd4d83e139 | ||
|
|
f1f7c1341c | ||
|
|
a7ab6945f9 | ||
|
|
896583aeec | ||
|
|
eccda2ed61 | ||
|
|
b819c757b7 | ||
|
|
b1f7acb76b | ||
|
|
c23df0c065 | ||
|
|
e514e24036 | ||
|
|
fdf9417c5b | ||
|
|
859468a03f | ||
|
|
4623d403f7 | ||
|
|
038c3c0fb9 | ||
|
|
7f0e2bf03d | ||
|
|
521ba8766c | ||
|
|
0c5cd2dc9e | ||
|
|
01655a3465 | ||
|
|
95b7dbfba3 | ||
|
|
5fc44e906f | ||
|
|
7bcfd8939e | ||
|
|
753624a185 | ||
|
|
5c9e41f4ff | ||
|
|
0a711ca4ba | ||
|
|
0c854a893b | ||
|
|
aab4284624 | ||
|
|
a336f6be89 | ||
|
|
79d99a9484 | ||
|
|
e4e1dc102b | ||
|
|
539ac26e0d | ||
|
|
4f597cfbbb | ||
|
|
8d500f153e | ||
|
|
ef53c859c3 | ||
|
|
0cf2cc4361 | ||
|
|
2c11b879ae | ||
|
|
58735094f1 | ||
|
|
3671803f49 | ||
|
|
56b42071ac | ||
|
|
ba930f72e8 | ||
|
|
d9d8717eeb | ||
|
|
0056324100 | ||
|
|
e49b4daafe | ||
|
|
ad86f1b55e | ||
|
|
a8bc0f037f | ||
|
|
9ae0c3f414 | ||
|
|
44ef4d08e7 | ||
|
|
bba5c51649 | ||
|
|
b7129340f3 | ||
|
|
d695e1b21b | ||
|
|
148bd1cfb7 | ||
|
|
e619d0f1ac | ||
|
|
475f486626 | ||
|
|
6108043fae | ||
|
|
76491090b0 | ||
|
|
5770a9c491 | ||
|
|
b8325d7387 | ||
|
|
f3a65dc169 | ||
|
|
d21495d5be | ||
|
|
d95ff25573 | ||
|
|
5dc170673f | ||
|
|
c148f7f23b | ||
|
|
1612e377d2 | ||
|
|
f03074e91a | ||
|
|
345847652f | ||
|
|
c71bc55e95 | ||
|
|
8521fe852f | ||
|
|
714444562f | ||
|
|
212c4a43c5 | ||
|
|
103dc55e1b | ||
|
|
b50161dc8f | ||
|
|
9d390a06bf | ||
|
|
635ca15722 | ||
|
|
e7c1b50cc1 | ||
|
|
21410566ef | ||
|
|
d6a4fddbb9 | ||
|
|
a911b1bd9a | ||
|
|
be4ee9436d | ||
|
|
6447d470f9 | ||
|
|
82aba8d276 | ||
|
|
eeec1f8bed | ||
|
|
2043c74937 | ||
|
|
844322a860 | ||
|
|
d458b9d825 | ||
|
|
cbd835b136 | ||
|
|
8260d38d0f | ||
|
|
4fd8a8877e | ||
|
|
b3d7ffa799 | ||
|
|
d6f20edbe5 | ||
|
|
7eda308388 | ||
|
|
1c256c2bba | ||
|
|
32f6a5664f | ||
|
|
13321ce5e2 | ||
|
|
d81e698d3b | ||
|
|
ee3123f45a | ||
|
|
4bf1a5215c | ||
|
|
d1b7c84473 | ||
|
|
75c02f8d3c | ||
|
|
86f583ada4 | ||
|
|
8d60264ee3 | ||
|
|
94d2406fa4 | ||
|
|
8e8d24c828 | ||
|
|
91bd600f93 | ||
|
|
11067b9818 | ||
|
|
7bfd841769 | ||
|
|
5c593a1025 | ||
|
|
625134605b | ||
|
|
1e0ae6e570 | ||
|
|
f3efd1e3bc | ||
|
|
dba8bd1862 | ||
|
|
d78e3a738d | ||
|
|
3898ad0c1c | ||
|
|
5632016220 | ||
|
|
12fdd70ad0 | ||
|
|
7fd6a6f83e | ||
|
|
515592e8ea | ||
|
|
5fa81e6c8a | ||
|
|
c22a139a5d | ||
|
|
bed95e54d2 | ||
|
|
f568415d8c | ||
|
|
c1137cf7b7 | ||
|
|
7f678945be | ||
|
|
bb72d43270 | ||
|
|
67524f6e53 | ||
|
|
b2a7b62902 | ||
|
|
a356bdeee3 | ||
|
|
1438e65c10 | ||
|
|
76043e87ad | ||
|
|
3b28bd6ce9 | ||
|
|
1236b16a3a | ||
|
|
5ec4e5339b | ||
|
|
0643f72a0b | ||
|
|
443b985b06 | ||
|
|
aecaba2a92 | ||
|
|
977f3d0635 | ||
|
|
5742493185 | ||
|
|
8b2d06c548 | ||
|
|
56b76080ec | ||
|
|
1a12659a23 | ||
|
|
d711ed986c | ||
|
|
53cb809996 | ||
|
|
ff0a77d6b5 | ||
|
|
596ec3230f | ||
|
|
fa75c8eb6f | ||
|
|
6c7a17fb81 | ||
|
|
0ad8f926cc | ||
|
|
764ab60fea | ||
|
|
9b13bdb06f | ||
|
|
9283ca8878 | ||
|
|
679ede1124 | ||
|
|
f4b1bde8c5 | ||
|
|
eff6cc3e17 | ||
|
|
03044b86b1 | ||
|
|
90ff88f02b | ||
|
|
e1f378977a | ||
|
|
a0f3d2862c | ||
|
|
0c64145368 | ||
|
|
a1474fb461 | ||
|
|
965ebc8669 | ||
|
|
3514152439 | ||
|
|
1b1dc7085e | ||
|
|
94d004724f | ||
|
|
e9f5d235cb | ||
|
|
9692afb323 | ||
|
|
3ae37a700b | ||
|
|
b166ca705e | ||
|
|
e6a67543f4 | ||
|
|
7d38f031f1 | ||
|
|
cf864e6f49 | ||
|
|
f4b1629a1d | ||
|
|
1fbb97021e | ||
|
|
229c815a59 | ||
|
|
ee8d78637d | ||
|
|
4ede842171 | ||
|
|
8306e78ce3 | ||
|
|
65d093ee9d | ||
|
|
aa48233a75 | ||
|
|
20d838f576 | ||
|
|
ae50e618c0 | ||
|
|
defa8b014e | ||
|
|
159d91f390 | ||
|
|
430d4ec93b | ||
|
|
ae64784daf | ||
|
|
18df7c32a4 | ||
|
|
4615612a65 | ||
|
|
6b226cdcad | ||
|
|
1776709b38 | ||
|
|
75a35cd680 | ||
|
|
18d4f2fa36 | ||
|
|
bca936f6a2 | ||
|
|
0b59379c4e | ||
|
|
58abdad62a | ||
|
|
c693414cc3 | ||
|
|
ee158e1aba | ||
|
|
40d478aaec | ||
|
|
e7fb96b3ff | ||
|
|
ef75427b45 | ||
|
|
2ff4f00774 | ||
|
|
534fd8c119 | ||
|
|
2d2cd89454 | ||
|
|
9f3f5ca7c1 | ||
|
|
c0e6f17c83 | ||
|
|
677f1cb42d | ||
|
|
1ac6a04a46 | ||
|
|
21927d2e25 | ||
|
|
c35441f5f7 | ||
|
|
3718fe6601 | ||
|
|
095f234bd5 | ||
|
|
f10c0e06db | ||
|
|
ebbd5d1fa3 | ||
|
|
c8abf26040 | ||
|
|
bc733af147 | ||
|
|
77a2fd2a60 | ||
|
|
5cfc5eb08a | ||
|
|
b832d2df26 | ||
|
|
db34dc40ba | ||
|
|
bfa48c2d3e | ||
|
|
b5e229a84d | ||
|
|
ef207f885b | ||
|
|
c4eee28335 | ||
|
|
6fe466779e | ||
|
|
e71db1b883 | ||
|
|
b6efafe99d | ||
|
|
688d466f8e | ||
|
|
3c5797932e | ||
|
|
48a5e262ce | ||
|
|
63b0365208 | ||
|
|
972c05d60b | ||
|
|
acb5778e0b | ||
|
|
7694c50358 | ||
|
|
7ffa368d10 | ||
|
|
53f8f41d88 | ||
|
|
93d57d847e | ||
|
|
83a4f5eec2 | ||
|
|
21a526dda9 | ||
|
|
836c2dba8d | ||
|
|
13ae8d2ebe | ||
|
|
98dafb4e49 | ||
|
|
85b8bae42e | ||
|
|
64416ef9ee | ||
|
|
6274ab3384 | ||
|
|
4ea5d94dc6 | ||
|
|
db4dd436b7 | ||
|
|
6906ac6c8f | ||
|
|
bc0b56a90e | ||
|
|
349d95b8ee | ||
|
|
8c8eb395de | ||
|
|
50c844aa25 | ||
|
|
85b232330b | ||
|
|
b4c305d094 | ||
|
|
be2ee3a029 | ||
|
|
596799bf2f | ||
|
|
10a405ef13 | ||
|
|
118c5d4b44 | ||
|
|
a4cb05080a | ||
|
|
699ececc42 | ||
|
|
4f8f698911 | ||
|
|
30083837a5 | ||
|
|
ea7aa6c52f | ||
|
|
244f2ed911 | ||
|
|
99a71c67e4 | ||
|
|
b3c0550a86 | ||
|
|
39a0c6d08a | ||
|
|
0ee494bcfc | ||
|
|
4233c743e2 | ||
|
|
eea00b0d53 | ||
|
|
7cef4d282c | ||
|
|
df6e085c97 | ||
|
|
70cf48355e | ||
|
|
da06c798d7 | ||
|
|
9055c094e8 | ||
|
|
5ab3ae3d59 | ||
|
|
92ebb2f339 | ||
|
|
d8c034dfa3 | ||
|
|
247516849e | ||
|
|
aad9996b6f | ||
|
|
76c1ad07a3 | ||
|
|
49599f48bb | ||
|
|
158d128316 | ||
|
|
00897e7388 | ||
|
|
1867f294c8 | ||
|
|
eaa189d6b0 | ||
|
|
87d11dcb0a | ||
|
|
ba99394890 | ||
|
|
6b8236021e | ||
|
|
68b12a579c | ||
|
|
3f0ae887b8 | ||
|
|
187f9a2246 | ||
|
|
e8566bc24b | ||
|
|
e375fc7d4d | ||
|
|
89157efce8 | ||
|
|
5e95291016 | ||
|
|
cbd6e668dc | ||
|
|
898e62490b | ||
|
|
326ab4edf1 | ||
|
|
f25a965478 | ||
|
|
f3f3bea7b3 | ||
|
|
583b0788c6 | ||
|
|
ca29cee586 | ||
|
|
ec905448b0 | ||
|
|
e8fa82d0de | ||
|
|
e381de812c | ||
|
|
1a9752d53b | ||
|
|
09cc5c5fd2 | ||
|
|
4e8d510d38 | ||
|
|
7f7f6bae80 | ||
|
|
358ff12fba | ||
|
|
8ff3ecb4d4 | ||
|
|
47df35f0fd | ||
|
|
e226851e03 | ||
|
|
7228907682 | ||
|
|
1dbabed716 | ||
|
|
4224cd037d | ||
|
|
9aa6d3f531 | ||
|
|
c9823ae9d0 | ||
|
|
1fa8a9e858 | ||
|
|
212e8893b9 | ||
|
|
367057421b | ||
|
|
c821480842 | ||
|
|
3ef7c11e3b | ||
|
|
1112756bc2 | ||
|
|
878ed43135 | ||
|
|
01b746d30a | ||
|
|
a900351729 | ||
|
|
4114a6e5d8 | ||
|
|
04dd232e38 | ||
|
|
bc26dfe856 | ||
|
|
0322f845af | ||
|
|
7daa2d63e6 | ||
|
|
3a8a41f568 | ||
|
|
65cd7d076b | ||
|
|
a1f71091fe | ||
|
|
131834af86 | ||
|
|
c0789cbdb9 | ||
|
|
7d12d866ab | ||
|
|
7c2589c35b | ||
|
|
fd2031ccf5 | ||
|
|
6a165ec9f4 | ||
|
|
fd8dad487a | ||
|
|
e9443b841a | ||
|
|
89ec6acac9 | ||
|
|
9b389b346f | ||
|
|
4a522bffc0 | ||
|
|
9b62f8b7a2 | ||
|
|
96342d67e3 | ||
|
|
95fc2bef9e | ||
|
|
4f36fbe3b9 | ||
|
|
a501d8b995 | ||
|
|
74691650b3 | ||
|
|
9325781cbc | ||
|
|
b383dd6419 | ||
|
|
fe98ecf0cc | ||
|
|
01970ab69b | ||
|
|
3aa252f681 | ||
|
|
18633291e6 | ||
|
|
b79619b6e4 | ||
|
|
dbb03ee688 | ||
|
|
00894b41d2 | ||
|
|
8868ace90d | ||
|
|
a7586eeba8 | ||
|
|
19b89c606a | ||
|
|
b6c1e7d11e | ||
|
|
ca90c89b2a | ||
|
|
170a758f5f | ||
|
|
43dcd6e7f4 | ||
|
|
063542b2f5 | ||
|
|
70e4ae4fb4 | ||
|
|
8f1a4c60df | ||
|
|
07212dba96 | ||
|
|
fb95caadbe | ||
|
|
51e86b686d | ||
|
|
6ec335087d | ||
|
|
96040e15fd | ||
|
|
12a5670441 | ||
|
|
b756fe2cdb | ||
|
|
3732a4c844 | ||
|
|
5bd59bd999 | ||
|
|
90ba5551d2 | ||
|
|
484b6891fd | ||
|
|
0435d5f0c7 | ||
|
|
864c8de8de | ||
|
|
8bcea5bfb8 | ||
|
|
beded04579 | ||
|
|
5dcd41170c | ||
|
|
3bb921a859 | ||
|
|
38edbde645 | ||
|
|
bcac7401ee | ||
|
|
c15f6519c0 | ||
|
|
2809b27be0 | ||
|
|
1b6e096bf9 | ||
|
|
85db37f6a7 | ||
|
|
0cee490466 | ||
|
|
a148c92da2 | ||
|
|
6272797834 | ||
|
|
c048134ef2 | ||
|
|
6f57cd9ffe | ||
|
|
76815f8194 |
146
README.md
146
README.md
@@ -1,34 +1,134 @@
|
||||

|
||||
|
||||
# Mastodon for Android Fork
|
||||
# Mastodos
|
||||
|
||||
## Changes
|
||||
> A fork of the [official Mastodon Android app](https://github.com/mastodon/mastodon-android) adding important features that are missing in the official app and possibly won’t ever be implemented, such as the federated timeline, unlisted posting, bookmarks and an image description viewer.
|
||||
|
||||
* [Enable "Unlisted" as a visibility option](https://github.com/sk22/mastodon-android-fork/tree/feature/enable-unlisted)
|
||||
([Pull request](https://github.com/mastodon/mastodon-android/pull/103)) and
|
||||
[set as default](https://github.com/sk22/mastodon-android-fork/tree/feature/enable-unlisted-as-default)
|
||||
* [Add "Federation" tab and change Discover tab order](https://github.com/sk22/mastodon-android-fork/tree/feature/add-federated-timeline) ([Fixes issue](https://github.com/mastodon/mastodon-android/issues/8))
|
||||
* [Add image description button and viewer](https://github.com/sk22/mastodon-android-fork/tree/feature/display-alt-text) ([Pull request](https://github.com/mastodon/mastodon-android/pull/129))
|
||||
* [Implement pinning posts and displaying pinned posts](https://github.com/sk22/mastodon-android-fork/tree/feature/pin-posts) ([Pull request](https://github.com/mastodon/mastodon-android/pull/140))
|
||||
* [Display full image when adding image description](https://github.com/sk22/mastodon-android-fork/tree/feature/compose-image-description-full-image) ([Pull request](https://github.com/mastodon/mastodon-android/pull/182))
|
||||
* [Always preserve content warnings when replying](https://github.com/sk22/mastodon-android-fork/tree/feature/always-preserve-cw) ([Fixes issue](https://github.com/mastodon/mastodon-android/issues/113))
|
||||
* [Make back button return to the home tab before exiting the app](https://github.com/sk22/mastodon-android-fork/tree/feature/back-returns-home) ([Fixes issue](https://github.com/mastodon/mastodon-android/issues/118))
|
||||
* [Implement a bookmark button and list](https://github.com/sk22/mastodon-android-fork/tree/feature/bookmarks) ([Fixes issue](https://github.com/mastodon/mastodon-android/issues/22))
|
||||
* [Implement deleting and re-drafting](https://github.com/sk22/mastodon-android-fork/tree/feature/delete-redraft) ([Fixes issue](https://github.com/mastodon/mastodon-android/issues/21))
|
||||
[](https://github.com/sk22/mastodos/releases/latest/download/mastodos.apk)
|
||||
|
||||
## Fork-specific changes
|
||||

|
||||
|
||||
* Custom app name
|
||||
---
|
||||
|
||||
|
||||
## Key features
|
||||
|
||||
### **Unlisted posting**
|
||||
|
||||
**Allows you to post publicly without having your post show up in trends, hashtags or public timelines (i.e., in the tabs “Local”, “Community” and “Posts”).**
|
||||
|
||||
When posting with Unlisted visibility, your posts will still be publicly accessible in your profile. They will also be shown in people’s Home timelines, but only if they follow you or someone they follow reposted/replied to your post.
|
||||
|
||||
The Mastodon documentation has some more information about [Unlisted posting](https://docs.joinmastodon.org/user/posting/#unlisted) and [Public timelines](https://docs.joinmastodon.org/user/network/#timelines).
|
||||
|
||||
### **Federated timeline**
|
||||
|
||||
**This allows you to chronologically see all Public posts from people on all other Fediverse instances your home instance is connected to.**
|
||||
|
||||
Despite being one of the main features of federated social media, the Federated timeline wasn’t included in the official Mastodon app – supposedly, because this conflicts with Google’s safety requirements for apps on the Play Store.
|
||||
|
||||
That’s one of the reasons why choosing a small, **well-moderated instance is important**. Instance admins and moderators should always make sure to ban abusive users and stop federating with instances who platform them. On well-moderated instances, the Federated timeline can be a welcoming place to meet new people!
|
||||
|
||||
### **Image description viewer**
|
||||
|
||||
**Allows you to quickly check whether an image or video has an alternative text attached to it.**
|
||||
|
||||
This is important to **ensure the content you’re sharing is as accessible as possible** to people who can’t see the images and rely on software to read back the provided content descriptions. Thankfully, it’s quite common for people on the Fediverse to provide such alt texts, and hopefully things stay this way!
|
||||
|
||||
### **Pinning posts**
|
||||
|
||||
**This lets you can highlight important posts on your profile. A dedicated “Pinned” tab in people’s profiles shows all the posts they pinned.**
|
||||
|
||||
On the Fediverse, it’s quite common for people to pin posts they want others to read before following them. You can pin/unpin posts yourself by clicking the `⋯` button in the top right corner of your posts.
|
||||
|
||||
### **Bookmarks**
|
||||
|
||||
**They allow for quickly saving posts and viewing them through the Bookmarks button on the top right of your profile.**
|
||||
|
||||
To bookmark a post, press the button between the Favorite and Share buttons on the bottom of the post. Bookmarks are saved privately, so the post authors won’t know you saved their post – the list of bookmarked posts is only visible to you.
|
||||
|
||||
## Installation
|
||||
|
||||
**Press the download button above to download the APK. Open the downloaded file on your Android device to install it. Mastodos will automatically notify you about new updates inside the app.**
|
||||
|
||||
To install this app on your Android device, download the [latest release from GitHub](https://github.com/sk22/mastodos/releases/latest/download/mastodos.apk) and open it. You might have to accept installing APK files from your browser when trying to install it. You can also take a look at all releases on the [Releases](https://github.com/sk22/mastodos/releases) page.
|
||||
|
||||
Mastodos makes use of [Mastodon for Android](https://github.com/mastodon/mastodon-android)’s automatic update checker. Mastodos will check for new updates available on GitHub and offer to download and install them. You can also manually press “Check for updates” at the bottom of the settings page!
|
||||
|
||||
### Other sources
|
||||
|
||||
* **[Izzy's F-Droid repository](https://apt.izzysoft.de/fdroid/repo)**: https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.sk
|
||||
|
||||
---
|
||||
|
||||
## Release variants
|
||||
|
||||
All downloads can be found on the [Releases](https://github.com/sk22/mastodos/releases) page.
|
||||
|
||||
**`mastodos.apk`**
|
||||
|
||||
Variant with an integrated updater. If you download Mastodos from here (and not from an app store), just download the regular `mastodos.apk`.
|
||||
|
||||
**`mastodon-1234abc.apk`**
|
||||
|
||||
This is an **unmodified version** of the official [Mastodon for Android](https://github.com/mastodon/mastodon-android) app the respective Mastodos release is based on. Should you find any bugs in Mastodos (which you will), try to see if it occurs with this variant, too. The last 7 digits of the file name are important to know which version of the official app you're using.
|
||||
|
||||
<!-- **`mastodon-fdroid.apk`**
|
||||
|
||||
Variant without the integrated updater. This is the variant to be published to F-Droid.org where an integrated updater is not necessary. -->
|
||||
|
||||
---
|
||||
|
||||
## Detailed changes
|
||||
|
||||
### Features
|
||||
|
||||
* [Add “Unlisted” as a post visibility option](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/enable-unlisted)
|
||||
([Pull request](https://github.com/mastodon/mastodon-android/pull/103))
|
||||
* [Add “Federation” tab and change Discover tab order](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/add-federated-timeline) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/8))
|
||||
* [Add image description button and viewer](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/display-alt-text) ([Pull request](https://github.com/mastodon/mastodon-android/pull/129))
|
||||
* [Implement pinning posts and displaying pinned posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/pin-posts) ([Pull request](https://github.com/mastodon/mastodon-android/pull/140))
|
||||
* [Implement deleting and re-drafting](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/delete-redraft) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/21))
|
||||
* [Implement a bookmark button and list](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/bookmarks) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/22))
|
||||
* [Add “Check for update” button in addition to integrated update checker](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/check-for-update-button)
|
||||
* [Add “Mark media as sensitive” option](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/mark-media-as-sensitive)
|
||||
* [Add settings to hide replies and reposts from the timeline](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/filter-home-timeline) ([Pull request](https://github.com/mastodon/mastodon-android/pull/317))
|
||||
* [Follow and unfollow hashtags](https://github.com/sk22/mastodos/commit/7d38f031f197aa6cefaf53e39d929538689c1e4e) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/233))
|
||||
* [Notification bell for posts](https://github.com/sk22/mastodos/commit/b166ca705eb9169025ef32bbe6315b42491b57ea) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/81))
|
||||
* [Viewing lists and adding/removing users from lists](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:list-timeline-views) based on [@obstsalatschuessel](https://github.com/obstsalatschuessel)'s [Pull request](https://github.com/mastodon/mastodon-android/pull/286)
|
||||
* [List favorited posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/favs-list)
|
||||
* [Accept/reject follow requests](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/follow-requests)
|
||||
* [Display content warning title above text](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/cw-above-text)
|
||||
* [Add notifications tab for posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/posts-notifications-tab)
|
||||
* [Show visibility of original post when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/display-reply-visibility)
|
||||
* [Clickable reply/boost line above posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:clickable-boost-reply-line)
|
||||
* [Long-click to copy username from profile](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/copy-username)
|
||||
|
||||
### Behavior
|
||||
|
||||
* [Make back button return to the home tab before exiting the app](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/back-returns-home) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/118))
|
||||
* [Always preserve content warnings when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/always-preserve-cw) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/113))
|
||||
* [Display full image when adding image description](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/compose-image-description-full-image) ([Pull request](https://github.com/mastodon/mastodon-android/pull/182))
|
||||
* [Set spoiler height independently to content height](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:spoiler-height-independent) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/166))
|
||||
* [Option to hide interaction numbers](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:settings/hide-interaction-numbers)
|
||||
* [Option to always reveal content warnings](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:feature/cw-above-text)
|
||||
* [Option to disable scrolling title bars](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:settings/disable-marquee)
|
||||
|
||||
### Visual
|
||||
|
||||
* [Custom extended footer redesign](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:compact-extended-footer)
|
||||
* [Improvements to the true black mode](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:true-black-improvements)
|
||||
* [Profile header tweaks](https://github.com/mastodon/mastodon-android/compare/master...sk22:mastodos:ui/profile-header-tweaks)
|
||||
|
||||
### Branding
|
||||
|
||||
* App name “Mastodos”
|
||||
* Pink primary color
|
||||
* Custom icon: Modulate upstream icon using ImageMagick
|
||||
|
||||
```bash
|
||||
mogrify -modulate 90,100,140 mastodon/src/main/res/mipmap-*/ic_launcher*.png
|
||||
mogrify -modulate 90,100,140 mastodon/src/main/res/mipmap-*/ic_launcher*.png mastodon/src/main/ic_launcher-playstore.png
|
||||
```
|
||||
|
||||
* Custom primary color: Hue of all `primary` colors in `colors.xml` is rotated, on basis of upstream Mastodon's [old branding](https://github.com/mastodon/mastodon-android/commit/74f03026cfcfcfd23237c38ff47d2b2a98a6f92a#diff-59134ec2a1cf3761f80b0ecccbbf8b9e433d9780d2f5c5d6ac3ac8cc254e808f)
|
||||
by `109.8°` (equivalent of `161%`, done by hand using
|
||||
[PineTools](https://pinetools.com/shift-hue-color))
|
||||
|
||||
## Building
|
||||
|
||||
As this app is using Java 17 features, you need JDK 17 or newer to build it. Other than that, everything is pretty standard. You can either import the project into Android Studio and build it from there, or run the following command in the project directory:
|
||||
@@ -40,3 +140,7 @@ 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).
|
||||
|
||||
## Links
|
||||
|
||||
<a rel="me" href="https://floss.social/@mastodos">@mastodos@floss.social</a>
|
||||
|
||||
2
_config.yml
Normal file
2
_config.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
title: Mastodos
|
||||
layout: default
|
||||
17
_layouts/default.html
Normal file
17
_layouts/default.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mastodos</title>
|
||||
<link rel="icon" href="mastodon/src/main/res/mipmap-mdpi/ic_launcher_round.png">
|
||||
<link rel="me" href="https://floss.social/@mastodon">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css">
|
||||
</head>
|
||||
<body class="markdown-body">
|
||||
<div style="margin: 0 auto; max-width: 45rem; padding: 2rem 1rem">
|
||||
{{ content }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
16
fastlane/metadata/android/bn-BD/full_description.txt
Normal file
16
fastlane/metadata/android/bn-BD/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re 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. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
1
fastlane/metadata/android/bn-BD/short_description.txt
Normal file
1
fastlane/metadata/android/bn-BD/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Decentralized social network
|
||||
1
fastlane/metadata/android/bn-BD/title.txt
Normal file
1
fastlane/metadata/android/bn-BD/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
@@ -1,16 +1,16 @@
|
||||
Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon!
|
||||
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!
|
||||
|
||||
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.
|
||||
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ě.
|
||||
|
||||
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
|
||||
Mastodon je 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í.
|
||||
|
||||
More features:
|
||||
Další funkce:
|
||||
|
||||
• 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
|
||||
• 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ění na nové sledování, odpovědi a reblogy
|
||||
• 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 is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
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.
|
||||
@@ -1 +1 @@
|
||||
Decentralized social network
|
||||
Decentralizovaná sociální síť
|
||||
@@ -1,6 +1,6 @@
|
||||
Mastodon ist das größte dezentralisierte soziale Netzwerk im Internet. Statt einer einzigen Website ist es ein Netzwerk von Millionen von Benutzer*innen in unabhängigen Gemeinschaften, die alle miteinander interagieren können. Egal was dich interessiert, auf Mastodon kannst du interessierte Leute 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 benutzerdefinierten Emojis, Bilderns, GIFs, Videos und Audio 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 benutzerdefinierten Emojis, Bildern, GIFs, Videos und Audio 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -8,9 +8,9 @@ Weitere Funktionen:
|
||||
|
||||
• Dunkler Modus: Beiträge im hellen, dunklen oder schwarzen Modus lesen
|
||||
• Umfragen: Frage deine Follower nach ihrer Meinung und zähle die Stimmen
|
||||
• Entdecken: Trending Hashtags und Accounts sind nur einen Fingertipp entfernt
|
||||
• Entdecken: trendende Hashtags und Profile sind nur einen Fingertipp entfernt
|
||||
• Benachrichtigungen: Erhalte Benachrichtigungen über neue Follower, Antworten und geteilte Beiträge
|
||||
• Teilen: Veröffentliche auf Mastodon aus jeder beliebigen anderen App
|
||||
• Niedlichkeit: Unser Maskottchen ist ein entzückender Elefant, und du wirst ihn von Zeit zu Zeit auftauchen sehen
|
||||
|
||||
Mastodon ist eine eingetragene gemeinnützige Organisation, und die Entwicklung wird direkt durch deine Spenden unterstützt. Es gibt keine Werbung, keine Monetisierung und kein Venture-Capital, und wir planen es so zu erhalten.
|
||||
Mastodon ist eine eingetragene gemeinnützige Organisation, und die Entwicklung wird direkt durch deine Spenden unterstützt. Es gibt keine Werbung, keine Monetisierung und kein Venture-Capital, und wir planen, das auch so beizubehalten.
|
||||
16
fastlane/metadata/android/gd-GB/full_description.txt
Normal file
16
fastlane/metadata/android/gd-GB/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re 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. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
1
fastlane/metadata/android/gd-GB/short_description.txt
Normal file
1
fastlane/metadata/android/gd-GB/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Decentralized social network
|
||||
1
fastlane/metadata/android/gd-GB/title.txt
Normal file
1
fastlane/metadata/android/gd-GB/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
@@ -1,16 +1,16 @@
|
||||
Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re 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.
|
||||
הצטרפו לקהילה וצרו את המשתמש שלכם. גלו ועקבו אחר אנשים מרתקים וקראו את הרשומות שלהם על גבי ציר זמן כרונולוגי וללא פרסומות. הביעו את עצמכם באמצעות אמוג׳ים מעוצבים, תמונות, גיפים, סרטונים, ושמע ברשומות של עד 500 תווים. הגיבו לשרשורים ועשו ״ריבלוג״ לרשומות של כל מי שאתם רוצים כדי לשתף דברים מגניבים. מצאו חשבונות חדשים לעקוב אחריהם והאשטגים טרנדיים כדי להרחיב את רשתותיכם.
|
||||
|
||||
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
|
||||
מסטודון בנויה עם פוקוס על פרטיות ובטיחות. קבעו האם הרשומות יתפרסמו עבור העוקבים שלכם, רק האנשים שאתם מציינים, או כל העולם. אזהרות תוכן מאפשרות לכם להסתיר רשומות המכילות תוכן רגיש או שעלול להוות טריגר עד שתהיו מוכנים להתמודד איתן. לכל קהילה כללים ומנהלים משלה שמטרתם לשמור על כך שכל חבריה יהיו בטוחים, וכלי חסימה ודיווח ענפים כדי לעזור למנוע שימוש לרעה.
|
||||
|
||||
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. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
מסטודון רשומה כארגון ללא מטרות רווח והפיתוח ממומן ישירות מתרומותכם. אין שום פרסומות, שום תרגום מובנה של הצלחה לכסף, ושום הון־סיכון, ואנחנו מתכננים לשמור על זה כך.
|
||||
@@ -1 +1 @@
|
||||
Decentralized social network
|
||||
רשת חברתית מבוזרת
|
||||
16
fastlane/metadata/android/hi-IN/full_description.txt
Normal file
16
fastlane/metadata/android/hi-IN/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re 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. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
1
fastlane/metadata/android/hi-IN/short_description.txt
Normal file
1
fastlane/metadata/android/hi-IN/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Decentralized social network
|
||||
1
fastlane/metadata/android/hi-IN/title.txt
Normal file
1
fastlane/metadata/android/hi-IN/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
16
fastlane/metadata/android/id-ID/full_description.txt
Normal file
16
fastlane/metadata/android/id-ID/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon adalah jejaring sosial terdesentralisasi terbesar di internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re 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. Temukan akun-akun baru untuk diikuti dan hashtag yang sedang tren untuk memperluas jejaring Anda.
|
||||
|
||||
Mastodon dibuat dengan fokus pada privasi dan keamanan. Tentukan apakah postingan Anda dibagikan kepada pengikut, hanya orang yang disebut, atau seluruh dunia. 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.
|
||||
|
||||
Fitur lainnya:
|
||||
|
||||
• 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. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
1
fastlane/metadata/android/id-ID/short_description.txt
Normal file
1
fastlane/metadata/android/id-ID/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Jaringan sosial terdesentralisasi
|
||||
1
fastlane/metadata/android/id-ID/title.txt
Normal file
1
fastlane/metadata/android/id-ID/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
16
fastlane/metadata/android/nl-NL/full_description.txt
Normal file
16
fastlane/metadata/android/nl-NL/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon is het grootste gedecentraliseerde sociale netwerk op het internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon!
|
||||
|
||||
Word lid van een community en maak je profiel aan. 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. Antwoord op berichten en boost iedereens berichten om geweldige dingen te delen. 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.
|
||||
|
||||
Meer mogelijkheden:
|
||||
|
||||
• 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
|
||||
• Meldingen: Krijg een melding over nieuwe volgers, reacties en boosts
|
||||
• 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. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
1
fastlane/metadata/android/nl-NL/short_description.txt
Normal file
1
fastlane/metadata/android/nl-NL/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Gedecentraliseerd sociaal netwerk
|
||||
1
fastlane/metadata/android/nl-NL/title.txt
Normal file
1
fastlane/metadata/android/nl-NL/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
@@ -1,8 +1,8 @@
|
||||
Mastodon to największa zdecentralizowana sieć społecznościowa w Internecie. Zamiast jednej strony internetowej, jest to sieć milionów użytkowników w niezależnych społecznościach, które mogą ze sobą wchodzić w interakcje. Niezależnie od swoich zainteresowań, momżesz poznać interesujących ludzi piszących o nich na Mastodonie!
|
||||
|
||||
Dołącz do społeczności i utwórz swój profil. Poznaj i obserwuj fascynujących ludzi i czytaj ich wpisy w chronologicznym osi czasu. Wyrażaj siebie za pomocą niestandardowych emoji, obrazów, GIFów, filmów i audio w 500-znakowych wpisach. Odpowiadaj na wątki i podawaj dalej posty od każdego, aby dzielić się wspaniałymi rzeczami. Odnajduj nowe konta do śledzenia i zyskujące popularność hashtagi, by poszerzać swoją sieć.
|
||||
Dołącz do społeczności i utwórz swój profil. Poznaj i obserwuj fascynujących ludzi i czytaj ich wpisy w chronologicznym osi czasu. Wyrażaj siebie za pomocą niestandardowych emoji, obrazów, GIFów, filmów i audio w 500-znakowych wpisach. Odpowiadaj na wątki i podawaj dalej posty od każdego, aby dzielić się wspaniałymi rzeczami. Odnajduj nowe konta do obserwowania i zyskujące popularność hashtagi, by poszerzać swoją sieć.
|
||||
|
||||
Mastodon został zaprojektowany z myślą o prywatności i bezpieczeństwie. Decyduj, czy Twoje wpisy są udostępniane osobom śledzącym Cię, tylko wzmiankowanym, czy całemu światu. Ostrzeżenia dotyczące zawartości pozwalają ukrywać posty zawierające wrażliwe lub wyzywające materiały, dopóki nie będziesz gotów się z nimi pogodzić. Każda społeczność ma własne wytyczne i moderatorów, aby zapewniać swoim członkom bezpieczeństwo, a także solidne narzędzia blokowania i raportowania pomagające zapobiegać nadużyciom.
|
||||
Mastodon został zaprojektowany z myślą o prywatności i bezpieczeństwie. Decyduj, czy Twoje wpisy są udostępniane osobom obserwującym Cię, tylko wzmiankowanym, czy całemu światu. Ostrzeżenia dotyczące zawartości pozwalają ukrywać posty zawierające wrażliwe lub wyzywające materiały, dopóki nie będziesz gotów się z nimi pogodzić. Każda społeczność ma własne wytyczne i moderatorów, aby zapewniać swoim członkom bezpieczeństwo, a także solidne narzędzia blokowania i raportowania pomagające zapobiegać nadużyciom.
|
||||
|
||||
Więcej funkcji:
|
||||
|
||||
@@ -13,4 +13,4 @@ Więcej funkcji:
|
||||
• Udostępnianie: Publikuj bezpośrednio na Mastodonie z menu udostępniania w dowolnej aplikacji
|
||||
• Słodycz: Nasza maskotka to uroczy słoń i zobaczysz go pojawiającego się od czasu do czasu
|
||||
|
||||
Mastodon to zarejestrowana organizacja non-profit i rozwój jest wspierany bezpośrednio przez darowizny. Nie ma reklam, monetyzacji, ani kapitału inwestycyjnego i planujemy, by tak pozostało.
|
||||
Mastodon to zarejestrowana organizacja non-profit, a rozwój jest wspierany bezpośrednio przez darowizny. Nie ma reklam, monetyzacji, ani kapitału inwestycyjnego i planujemy, by tak pozostało.
|
||||
@@ -1,16 +1,16 @@
|
||||
Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon!
|
||||
Mastodon é a maior rede social descentralizada na internet. Ao invés de ser um ‘website’, é uma rede de milhões de usuários em comunidades independentes que podem comunicar-se uma à outra, de forma transparente. Independente do que você gostar, você pode encontrar pessoas dedicadas a isso no 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.
|
||||
Junte-se a uma comunidade e crie o seu perfil. Encontre e siga pessoas fascinantes e leia seus posts em uma linha cronológica sem publicidade. Se expresse com emojis personalizados, imagens, GIFs, vídeos e áudio em publicações de 500 caracteres. Responda a tópicos e reblogue publicações de qualquer um para compartilhar coisas ótimas. Encontre contas novas para seguir e hashtags em alta para expandir sua rede.
|
||||
|
||||
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.
|
||||
O Mastodon foi construído com foco em privacidade e segurança. Decida se seus posts serão compartilhados com seus seguidores, apenas com as pessoas que você menciona, ou com o mundo inteiro. Avisos de conteúdo permitem que você oculte mensagens que contenham conteúdo sensível até você estar pronto para interagir com elas. Cada comunidade tem suas próprias diretrizes e moderadores para manter seus membros seguros, e ferramentas robustas de bloqueio e denúncias ajudam a prevenir abusos.
|
||||
|
||||
More features:
|
||||
Mais detalhes:
|
||||
|
||||
• 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
|
||||
• Modo escuro: Leia as postagens no modo claro, escuro ou preto de verdade
|
||||
• Enquetes: Peça as opiniões de seus seguidores e registre os resultados
|
||||
• Explorar: Hashtags e contas em destaque estão a um toque de distância
|
||||
• Notificações: Seja notificado sobre novos seguidores, respostas e reblogs
|
||||
• Compartilhar: Poste diretamente ao Mastodon pela página de compartilhamento de qualquer aplicativo
|
||||
• Fofura: Nossa mascote é um elefante fofinho, e você vai vê-lo por aí ocasionalmente
|
||||
|
||||
Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
Mastodon é uma instituição sem fins lucrativos e o desenvolvimento é suportado diretamente pelas suas doações. Não há publicidade, não há monetização, nem capital de risco, e planejamos manter-se assim.
|
||||
@@ -1 +1 @@
|
||||
Decentralized social network
|
||||
Rede social descentralizada
|
||||
16
fastlane/metadata/android/si-LK/full_description.txt
Normal file
16
fastlane/metadata/android/si-LK/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re 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. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
1
fastlane/metadata/android/si-LK/short_description.txt
Normal file
1
fastlane/metadata/android/si-LK/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Decentralized social network
|
||||
1
fastlane/metadata/android/si-LK/title.txt
Normal file
1
fastlane/metadata/android/si-LK/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
@@ -1,6 +1,6 @@
|
||||
Mastodon är det största decentraliserade sociala nätverket på internet. I stället för en enda webbplats är det ett nätverk av miljontals användare på oberoende servrar som alla kan interagera med varandra, sömlöst. Oavsett vad du är intresserad av kan du träffa passionerade personer som diskuterar ämnet på Mastodon!
|
||||
|
||||
Gå med på en server och skapa din profil. Hitta och följ fascinerande människor och läsa deras inlägg i en annonsfri, kronologisk tidslinje. Uttryck dig med anpassade emoji, bilder, GIF:ar, videor och ljud i 500-teckensinlägg. Svara på trådar och ompostningar från vem som helst för att dela bra saker. Hitta nya konton att följa och trendande hashtaggar för att utöka ditt nätverk.
|
||||
Gå med på en server och skapa din profil. Hitta och följ fascinerande människor och läsa deras inlägg i en annonsfri, kronologisk tidslinje. Uttryck dig med anpassade emoji, bilder, GIF:ar, videor och ljud i 500-teckensinlägg. Svara på trådar och boostar från vem som helst för att dela bra saker. Hitta nya konton att följa och trendande hashtaggar för att utöka ditt nätverk.
|
||||
|
||||
Mastodon är byggt med fokus på integritet och trygghet. Bestäm om dina inlägg delas med dina följare, bara personer du omnämner, eller hela världen. Innehållsvarningar låter dig dölja inlägg som innehåller känsligt eller triggande material tills du är redo att interagera med dem. Varje server har sina egna riktlinjer och moderatorer för att hålla sina medlemmar trygga, och robusta blockerings- och rapporteringsverktyg för att förhindra missbruk.
|
||||
|
||||
@@ -9,7 +9,7 @@ Fler funktioner:
|
||||
• Mörkt läge: Läs inlägg i ljust, mörkt eller helsvart läge
|
||||
• Omröstningar: Fråga följare om deras åsikt och sammanställ deras röster
|
||||
• Utforska: Trendande hashtaggar och konton är ett tryck bort
|
||||
• Notiser: Bli meddelad om nya följare, svar och ompostningar
|
||||
• Notiser: Bli meddelad om nya följare, svar och boostar
|
||||
• Delning: Posta direkt till Mastodon från delningsbladet i alla appar
|
||||
• Gullighet: Vår maskot är en bedårande elefant, och du kommer att se dem dyka upp då och då
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Mastodon เป็นเครือข่ายสังคมแบบกระจายศูนย์ที่ใหญ่ที่สุดบนอินเทอร์เน็ต ซึ่งไม่ได้เป็นเว็บไซต์เดียว แต่เป็นเครือข่ายของผู้ใช้หลายล้านคนในชุมชนอิสระที่ทุกคนสามารถโต้ตอบซึ่งกันและกันได้แบบไร้รอยต่อ ไม่ว่าคุณจะชอบอะไร คุณก็พบคนที่ชื่นชอบเหมือนกันโพสต์เกี่ยวกับสิ่งที่คุณชอบได้บน Mastodon!
|
||||
Mastodon เป็นเครือข่ายสังคมแบบกระจายศูนย์ที่ใหญ่ที่สุดบนอินเทอร์เน็ต ซึ่งไม่ได้เป็นเว็บไซต์เดียว แต่เป็นเครือข่ายของผู้ใช้หลายล้านคนในชุมชนอิสระที่ทุกคนสามารถโต้ตอบซึ่งกันและกันได้แบบไร้รอยต่อ ไม่ว่าคุณจะชอบอะไร คุณก็พบคนที่ชื่นชอบเหมือนกันโพสต์เกี่ยวกับสิ่งที่คุณชอบได้บน Mastodon! ซึ่งไม่ได้เป็นเว็บไซต์เดียว แต่เป็นเครือข่ายของผู้ใช้หลายล้านคนในชุมชนอิสระที่ทุกคนสามารถโต้ตอบซึ่งกันและกันได้แบบไร้รอยต่อ ไม่ว่าคุณจะชอบอะไร คุณก็พบคนที่ชื่นชอบเหมือนกันโพสต์เกี่ยวกับสิ่งที่คุณชอบได้บน Mastodon!
|
||||
|
||||
เข้าร่วมชุมชนและสร้างโปรไฟล์ ค้นหาและติดตามผู้คนที่น่าสนใจและอ่านโพสต์ของเขาในไทม์ไลน์ที่ไร้โฆษณาและเรียงตามลำดับเวลาล้วน ๆ แสดงความรู้สึกของตัวคุณเองด้วยอีโมจิที่กำหนดเอง รูปภาพ GIF วิดีโอ และเสียงในโพสต์ 500 ตัวอักษร ตอบกลับและดันโพสต์จากคนอื่น ๆ เพื่อแชร์สิ่งดี ๆ และค้นหาบัญชีใหม่ ๆ ที่จะติดตามและแฮชแท็กที่เป็นที่นิยมเพื่อขยายเครือข่ายของคุณ
|
||||
|
||||
@@ -13,4 +13,4 @@ Mastodon สร้างขึ้นโดยเน้นความเป็
|
||||
• การแชร์: โพสต์ลง Mastodon ได้โดยตรงจากแอปอื่น ๆ ที่อยู่ในเครื่อง
|
||||
• ความน่ารัก: มาสคอตของเราเป็นช้างน่ารัก และคุณจะเห็นมันโผล่ออกมาเป็นระยะ ๆ
|
||||
|
||||
Mastodon เป็นองค์กรไม่แสวงหาผลกำไรที่จดทะเบียนแล้ว และการพัฒนาได้รับการสนับสนุนจากเงินบริจาคของคุณโดยตรง ดังนั้นจึงไม่มีโฆษณา ไม่มีการทำกำไร และไม่มีการร่วมลงทุน และเรามีแผนจะทำให้เป็นอย่างนี้ต่อไป
|
||||
Mastodon เป็นองค์กรไม่แสวงหาผลกำไรที่จดทะเบียนแล้ว และการพัฒนาได้รับการสนับสนุนจากเงินบริจาคของคุณโดยตรง ดังนั้นจึงไม่มีโฆษณา ไม่มีการทำกำไร และไม่มีการร่วมลงทุน และเรามีแผนจะทำให้เป็นอย่างนี้ต่อไป ดังนั้นจึงไม่มีโฆษณา ไม่มีการทำกำไร และไม่มีการร่วมลงทุน และเรามีแผนจะทำให้เป็นอย่างนี้ต่อไป
|
||||
@@ -1,6 +1,6 @@
|
||||
Mastodon là mạng xã hội liên hợp lớn nhất trên internet. Thay vì một trang web duy nhất, nó là một mạng lưới hàng triệu người dùng trong các máy chủ độc lập, tất cả đều có thể tương tác với nhau một cách liền mạch. Bất kể bạn thích gì, bạn đều có thể gặp gỡ những người đăng tút về nó trên Mastodon!
|
||||
Mastodon là mạng xã hội liên hợp lớn nhất trên internet. Thay vì một trang web duy nhất, nó là một mạng lưới hàng triệu người trong các máy chủ độc lập, tất cả đều có thể tương tác với nhau một cách liền mạch. Bất kể bạn thích gì, bạn đều có thể gặp gỡ những người đăng tút về nó trên Mastodon!
|
||||
|
||||
Tham gia một máy chủ và tạo trang hồ sơ của bạn. Tìm, theo dõi những người thú vị và đọc tút của họ theo trình tự thời gian, không có quảng cáo. Thể hiện bản thân bằng emoji, hình ảnh, GIF, video và âm thanh trong tút tối đa 500 ký tự. Trả lời tút và đăng lại tút từ bất kỳ ai để chia sẻ những điều tuyệt vời. Tìm những người dùng mới để theo dõi và các hashtag xu hướng để mở rộng mạng lưới của bạn.
|
||||
Tham gia một máy chủ và tạo trang hồ sơ của bạn. Tìm, theo dõi những người thú vị và đọc tút của họ theo trình tự thời gian, không có quảng cáo. Thể hiện bản thân bằng emoji, hình ảnh, GIF, video và âm thanh trong tút tối đa 500 ký tự. Trả lời tút và đăng lại tút từ bất kỳ ai để chia sẻ những điều tuyệt vời. Tìm những người mới để theo dõi và các hashtag nổi bật để mở rộng mạng lưới của bạn.
|
||||
|
||||
Mastodon được xây dựng tập trung vào sự riêng tư và an toàn. Quyết định xem tút của bạn được chia sẻ với những người theo dõi, chỉ những người bạn nhắc đến hay cả thế giới. Nội dung ẩn cho phép bạn ẩn các tút chứa nội dung nhạy cảm hoặc chơi chữ cho đến khi bạn sẵn sàng tương tác với chúng. Mỗi máy chủ có các nguyên tắc riêng và kiểm duyệt viên riêng để giữ an toàn cho các thành viên, song song với các công cụ chặn và báo cáo mạnh mẽ giúp ngăn chặn hành vi bậy.
|
||||
|
||||
@@ -8,7 +8,7 @@ Tính năng khác:
|
||||
|
||||
• Chế độ Tối: Đọc tút ở chế độ sáng, tối hoặc đen
|
||||
• Bình chọn: Hỏi cộng đồng về ý kiến của họ và đếm lượt bình chọn
|
||||
• Khám phá: Xem hashtag xu hướng và tài khoản chỉ bằng một nhấn
|
||||
• Khám phá: Xem hashtag nổi bật và tài khoản chỉ bằng một nhấn
|
||||
• Thông báo: Nhận thông báo về người theo dõi, lượt trả lời và đăng lại mới
|
||||
• Chia sẻ: Đăng trực tiếp lên Mastodon từ bất kỳ ứng dụng nào
|
||||
• Đáng yêu: Linh vật của chúng tôi là một chú voi ma mút và bạn sẽ thấy anh ấy thỉnh thoảng xuất hiện
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Mastodon 是互联网上最大的去中心化社交网络。 它不是一个网站,而是由独立社区节点及其数以百万计的用户组成的网络,所有这些用户都能够无缝地相互交流。 无论你进入哪一个节点,你都可以与所有在 Mastodon 的人之间进行交流。
|
||||
Mastodon 是互联网上最大的去中心化社交网络。 它不是一个网站,而是由独立社区节点及其数以百万计的用户组成的网络,所有这些用户都能够无缝地相互交流。 无论你进入哪一个节点,你都可以与所有在 Mastodon 的人之间进行交流。 它不是一个网站,而是由独立社区节点及其数以百万计的用户组成的网络,所有这些用户都能够无缝地相互交流。 无论你进入哪一个节点,你都可以与所有在 Mastodon 的人之间进行交流。
|
||||
|
||||
加入一个社区节点并创建你的账户。 寻找并关注感兴趣的同好,无广告地浏览他们的时间线。 用自定义表情、图像、GIF、视频和音频在 500 个字符的帖子里写下你的想法。 回复及转发其他人的帖子来共同分享美好的事物。 寻找新的账户以及热门的话题来拓展你的网络。
|
||||
|
||||
@@ -13,4 +13,4 @@ Mastodon 以隐私和安全为首要目标。 自由选择你的帖子是分享
|
||||
• 分享:从其他应用中的分享菜单中直接发布到 Mastodon
|
||||
• 吉祥物:你会不时地看到我们可爱的长毛象
|
||||
|
||||
Mastodon 是一个注册的非营利开发项目,直接由您的捐赠支持。 没有广告,没有商业化,也没有风险资本,我们计划保持这种方式。
|
||||
Mastodon 是一个注册的非营利开发项目,直接由您的捐赠支持。 没有广告,没有商业化,也没有风险资本,我们计划保持这种方式。 没有广告,没有商业化,也没有风险资本,我们计划保持这种方式。
|
||||
@@ -1,16 +1,16 @@
|
||||
Mastodon 是網際網路上最大的去中心化社交網路。 它是一個由能無縫互動的獨立社群中,數百萬使用者組成的網路,而非單一網站。 無論您對什麼事情感興趣,您都能在 Mastodon 上遇到充滿熱情的人們討論該話題。
|
||||
Mastodon 是網際網路上最大的去中心化社交網路。 它是一個由能無縫互動的獨立社群中,數百萬使用者組成的網路,而非單一網站。 無論您對什麼事情感興趣,您都能在 Mastodon 上遇到充滿熱情的人們討論該話題。 它是一個由能無縫互動的獨立社群中,數百萬使用者組成的網路,而非單一網站。 Mastodon 以隱私與安全為要。 決定您的嘟文要與您的跟隨者分享、只與您提及的人們分享,又或是與全世界分享。 內容警告可讓您隱藏包含敏感或可能觸發強烈情緒反應的嘟文,直到您準備好與它們進行互動。 每個社群都有它們自己的指導方針與管理原來確保其成員安全,強大的封鎖與回報工具有助於防止濫用。
|
||||
|
||||
加入社群並建立您的個人檔案。 尋找並追蹤迷人的夥伴,並在無廣告、按時間順序排列的時間軸上閱讀他們的貼文。 在 500 個字元的貼文中使用自訂表情符號、GIF、視訊與音訊來表達您自己。 回覆任何人的話題與轉發貼文以分享精彩內容。 尋找要追蹤的新帳號與熱門主題標籤來拓展您的網路。
|
||||
加入社群並建立您的個人檔案。 尋找並跟隨迷人的朋友們,並在無廣告、按時間順序排列的時間軸上閱讀他們的嘟文。 在 500 個字元的嘟文中使用自訂表情符號、GIF、視訊與音訊來表達您自己。 回覆任何人的話題與轉發貼文以分享精彩內容。 尋找要跟隨的新帳號與熱門主題標籤來拓展您的網路。
|
||||
|
||||
Mastodon 以隱私與安全為要。 決定您的貼文要與您的追蹤者分享、只與您提及的人們分享,又或是與全世界分享。 內容警告可讓您隱藏包含敏感或可能觸發強烈情緒反應的貼文,直到您準備好與它們進行互動。 每個社群都有它們自己的指導方針與管理原來確保其成員安全,強大的封鎖與回報工具有助於防止濫用。
|
||||
Mastodon 以隱私與安全為要。 決定您的嘟文要與您的跟隨者分享、只與您提及的人們分享,又或是與全世界分享。 內容警告可讓您隱藏包含敏感或可能觸發強烈情緒反應的嘟文,直到您準備好與它們進行互動。 每個社群都有它們自己的指導方針與管理原來確保其成員安全,強大的封鎖與回報工具有助於防止濫用。
|
||||
|
||||
更多功能:
|
||||
|
||||
• 深色模式:以淺色、深色或純黑色模式閱讀貼文
|
||||
• 投票:詢問追蹤的意見並計票
|
||||
• 投票:詢問跟隨者們的意見並計票
|
||||
• 探索:僅需輕點一下,即可看到熱門主題標籤與帳號
|
||||
• 通知:取得關於新追蹤、回覆與轉發的通知
|
||||
• 分享:從任何應用程式中的分享表中直接發表貼文到 Mastodon 中
|
||||
• 通知:取得關於新跟隨者們、回覆與轉發的通知
|
||||
• 分享:從任何應用程式中的分享表中直接發表嘟文到 Mastodon 中
|
||||
• 可愛:我們的吉祥物是一隻可愛的大象,您會不時看到牠出現
|
||||
|
||||
Mastodon 是一家註冊的非營利組織,您的捐款會直接支援開發工作。 沒有廣告、沒有貨幣化、沒有風險投資,我們計畫維持這種狀態。
|
||||
Mastodon 是一家註冊的非營利組織,您的捐款會直接支援開發工作。 沒有廣告、沒有貨幣化、沒有風險投資,我們計畫維持這種狀態。 沒有廣告、沒有貨幣化、沒有風險投資,我們計畫維持這種狀態。
|
||||
3
get-upstream-commit.sh
Executable file
3
get-upstream-commit.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
git rev-parse --short --verify upstream/master
|
||||
BIN
img/banner.png
Normal file
BIN
img/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 501 KiB |
@@ -4,24 +4,29 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 31
|
||||
compileSdk 33
|
||||
defaultConfig {
|
||||
applicationId "org.joinmastodon.android.sk"
|
||||
minSdk 23
|
||||
targetSdk 31
|
||||
versionCode 21
|
||||
versionName '1.1.3+fork.21'
|
||||
targetSdk 33
|
||||
versionCode 47
|
||||
versionName "1.1.4+fork.47"
|
||||
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"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
// minifyEnabled true
|
||||
// shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
debug{
|
||||
debuggable true
|
||||
versionNameSuffix '-debug'
|
||||
}
|
||||
appcenterPrivateBeta{
|
||||
initWith release
|
||||
@@ -33,6 +38,9 @@ android {
|
||||
initWith release
|
||||
versionNameSuffix "-beta"
|
||||
}
|
||||
githubRelease{
|
||||
initWith release
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
@@ -46,6 +54,12 @@ android {
|
||||
appcenterPublicBeta{
|
||||
setRoot "src/appcenter"
|
||||
}
|
||||
githubRelease{
|
||||
setRoot "src/github"
|
||||
}
|
||||
debug {
|
||||
setRoot "src/github"
|
||||
}
|
||||
}
|
||||
lintOptions{
|
||||
checkReleaseBuilds false
|
||||
@@ -62,7 +76,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'
|
||||
|
||||
21
mastodon/src/github/AndroidManifest.xml
Normal file
21
mastodon/src/github/AndroidManifest.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.joinmastodon.android">
|
||||
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
|
||||
<application>
|
||||
<!-- <receiver android:name=".updater.GithubSelfUpdaterImpl$InstallerStatusReceiver" android:exported="false"/>-->
|
||||
<!-- <receiver android:name=".updater.GithubSelfUpdaterImpl$AfterUpdateRestartReceiver" android:exported="true" android:enabled="false">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- </receiver>-->
|
||||
<provider
|
||||
android:authorities="${applicationId}.self_update_provider"
|
||||
android:name=".updater.SelfUpdateContentProvider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false"/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,356 @@
|
||||
package org.joinmastodon.android.updater;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.DownloadManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageInstaller;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import org.joinmastodon.android.BuildConfig;
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.MastodonAPIController;
|
||||
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
@Keep
|
||||
public class GithubSelfUpdaterImpl extends GithubSelfUpdater{
|
||||
private static final long CHECK_PERIOD=6*3600*1000L;
|
||||
private static final String TAG="GithubSelfUpdater";
|
||||
|
||||
private UpdateState state=UpdateState.NO_UPDATE;
|
||||
private UpdateInfo info;
|
||||
private long downloadID;
|
||||
private BroadcastReceiver downloadCompletionReceiver=new BroadcastReceiver(){
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent){
|
||||
if(downloadID!=0 && intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)==downloadID){
|
||||
MastodonApp.context.unregisterReceiver(this);
|
||||
setState(UpdateState.DOWNLOADED);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public GithubSelfUpdaterImpl(){
|
||||
SharedPreferences prefs=getPrefs();
|
||||
int checkedByBuild=prefs.getInt("checkedByBuild", 0);
|
||||
if(prefs.contains("version") && checkedByBuild==BuildConfig.VERSION_CODE){
|
||||
info=new UpdateInfo();
|
||||
info.version=prefs.getString("version", null);
|
||||
info.size=prefs.getLong("apkSize", 0);
|
||||
downloadID=prefs.getLong("downloadID", 0);
|
||||
if(downloadID==0 || !getUpdateApkFile().exists()){
|
||||
state=UpdateState.UPDATE_AVAILABLE;
|
||||
}else{
|
||||
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
|
||||
state=dm.getUriForDownloadedFile(downloadID)==null ? UpdateState.DOWNLOADING : UpdateState.DOWNLOADED;
|
||||
if(state==UpdateState.DOWNLOADING){
|
||||
MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
|
||||
}
|
||||
}
|
||||
}else if(checkedByBuild!=BuildConfig.VERSION_CODE && checkedByBuild>0){
|
||||
// We are in a new version, running for the first time after update. Gotta clean things up.
|
||||
long id=getPrefs().getLong("downloadID", 0);
|
||||
if(id!=0){
|
||||
MastodonApp.context.getSystemService(DownloadManager.class).remove(id);
|
||||
}
|
||||
getUpdateApkFile().delete();
|
||||
getPrefs().edit()
|
||||
.remove("apkSize")
|
||||
.remove("version")
|
||||
.remove("apkURL")
|
||||
.remove("checkedByBuild")
|
||||
.remove("downloadID")
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
|
||||
private SharedPreferences getPrefs(){
|
||||
return MastodonApp.context.getSharedPreferences("githubUpdater", Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeCheckForUpdates(){
|
||||
if(state!=UpdateState.NO_UPDATE && state!=UpdateState.UPDATE_AVAILABLE)
|
||||
return;
|
||||
long timeSinceLastCheck=System.currentTimeMillis()-getPrefs().getLong("lastCheck", CHECK_PERIOD);
|
||||
if(timeSinceLastCheck>=CHECK_PERIOD){
|
||||
setState(UpdateState.CHECKING);
|
||||
MastodonAPIController.runInBackground(this::actuallyCheckForUpdates);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkForUpdates() {
|
||||
setState(UpdateState.CHECKING);
|
||||
MastodonAPIController.runInBackground(this::actuallyCheckForUpdates);
|
||||
}
|
||||
|
||||
private void actuallyCheckForUpdates(){
|
||||
Request req=new Request.Builder()
|
||||
.url("https://api.github.com/repos/sk22/mastodos/releases/latest")
|
||||
.build();
|
||||
Call call=MastodonAPIController.getHttpClient().newCall(req);
|
||||
try(Response resp=call.execute()){
|
||||
JsonObject obj=JsonParser.parseReader(resp.body().charStream()).getAsJsonObject();
|
||||
String tag=obj.get("tag_name").getAsString();
|
||||
Pattern pattern=Pattern.compile("v?(\\d+)\\.(\\d+)\\.(\\d+)\\+fork\\.(\\d+)");
|
||||
Matcher matcher=pattern.matcher(tag);
|
||||
if(!matcher.find()){
|
||||
Log.w(TAG, "actuallyCheckForUpdates: release tag has wrong format: "+tag);
|
||||
return;
|
||||
}
|
||||
int newMajor=Integer.parseInt(matcher.group(1)),
|
||||
newMinor=Integer.parseInt(matcher.group(2)),
|
||||
newRevision=Integer.parseInt(matcher.group(3)),
|
||||
newForkNumber=Integer.parseInt(matcher.group(4));
|
||||
matcher=pattern.matcher(BuildConfig.VERSION_NAME);
|
||||
String[] currentParts=BuildConfig.VERSION_NAME.split("[.+]");
|
||||
if(!matcher.find()){
|
||||
Log.w(TAG, "actuallyCheckForUpdates: current version has wrong format: "+BuildConfig.VERSION_NAME);
|
||||
return;
|
||||
}
|
||||
int curMajor=Integer.parseInt(matcher.group(1)),
|
||||
curMinor=Integer.parseInt(matcher.group(2)),
|
||||
curRevision=Integer.parseInt(matcher.group(3)),
|
||||
curForkNumber=Integer.parseInt(matcher.group(4));
|
||||
long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision;
|
||||
long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision;
|
||||
if(newVersion>curVersion || newForkNumber>curForkNumber){
|
||||
String version=newMajor+"."+newMinor+"."+newRevision+"+fork."+newForkNumber;
|
||||
Log.d(TAG, "actuallyCheckForUpdates: new version: "+version);
|
||||
for(JsonElement el:obj.getAsJsonArray("assets")){
|
||||
JsonObject asset=el.getAsJsonObject();
|
||||
if("mastodos.apk".equals(asset.get("name")) && "application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){
|
||||
long size=asset.get("size").getAsLong();
|
||||
String url=asset.get("browser_download_url").getAsString();
|
||||
|
||||
UpdateInfo info=new UpdateInfo();
|
||||
info.size=size;
|
||||
info.version=version;
|
||||
this.info=info;
|
||||
|
||||
getPrefs().edit()
|
||||
.putLong("apkSize", size)
|
||||
.putString("version", version)
|
||||
.putString("apkURL", url)
|
||||
.putInt("checkedByBuild", BuildConfig.VERSION_CODE)
|
||||
.remove("downloadID")
|
||||
.apply();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
getPrefs().edit().putLong("lastCheck", System.currentTimeMillis()).apply();
|
||||
}catch(Exception x){
|
||||
Log.w(TAG, "actuallyCheckForUpdates", x);
|
||||
}finally{
|
||||
setState(info==null ? UpdateState.NO_UPDATE : UpdateState.UPDATE_AVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void setState(UpdateState state){
|
||||
this.state=state;
|
||||
E.post(new SelfUpdateStateChangedEvent(state));
|
||||
}
|
||||
|
||||
@Override
|
||||
public UpdateState getState(){
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UpdateInfo getUpdateInfo(){
|
||||
return info;
|
||||
}
|
||||
|
||||
public File getUpdateApkFile(){
|
||||
return new File(MastodonApp.context.getExternalCacheDir(), "update.apk");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void downloadUpdate(){
|
||||
if(state==UpdateState.DOWNLOADING)
|
||||
throw new IllegalStateException();
|
||||
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
|
||||
MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
|
||||
downloadID=dm.enqueue(
|
||||
new DownloadManager.Request(Uri.parse(getPrefs().getString("apkURL", null)))
|
||||
.setDestinationUri(Uri.fromFile(getUpdateApkFile()))
|
||||
);
|
||||
getPrefs().edit().putLong("downloadID", downloadID).apply();
|
||||
setState(UpdateState.DOWNLOADING);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void installUpdate(Activity activity){
|
||||
if(state!=UpdateState.DOWNLOADED)
|
||||
throw new IllegalStateException();
|
||||
Uri uri;
|
||||
Intent intent=new Intent(Intent.ACTION_INSTALL_PACKAGE);
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
|
||||
uri=new Uri.Builder().scheme("content").authority(activity.getPackageName()+".self_update_provider").path("update.apk").build();
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
}else{
|
||||
uri=Uri.fromFile(getUpdateApkFile());
|
||||
}
|
||||
intent.setDataAndType(uri, "application/vnd.android.package-archive");
|
||||
activity.startActivity(intent);
|
||||
|
||||
// TODO figure out how to restart the app when updating via this new API
|
||||
/*
|
||||
PackageInstaller installer=activity.getPackageManager().getPackageInstaller();
|
||||
try{
|
||||
final int sid=installer.createSession(new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL));
|
||||
installer.registerSessionCallback(new PackageInstaller.SessionCallback(){
|
||||
@Override
|
||||
public void onCreated(int i){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBadgingChanged(int i){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActiveChanged(int i, boolean b){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgressChanged(int id, float progress){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFinished(int id, boolean success){
|
||||
activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
|
||||
}
|
||||
});
|
||||
activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
|
||||
PackageInstaller.Session session=installer.openSession(sid);
|
||||
try(OutputStream out=session.openWrite("mastodon.apk", 0, info.size); InputStream in=new FileInputStream(getUpdateApkFile())){
|
||||
byte[] buffer=new byte[16384];
|
||||
int read;
|
||||
while((read=in.read(buffer))>0){
|
||||
out.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
// PendingIntent intent=PendingIntent.getBroadcast(activity, 1, new Intent(activity, InstallerStatusReceiver.class), PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE);
|
||||
PendingIntent intent=PendingIntent.getActivity(activity, 1, new Intent(activity, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
session.commit(intent.getIntentSender());
|
||||
}catch(IOException x){
|
||||
Log.w(TAG, "installUpdate", x);
|
||||
Toast.makeText(activity, x.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getDownloadProgress(){
|
||||
if(state!=UpdateState.DOWNLOADING)
|
||||
throw new IllegalStateException();
|
||||
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
|
||||
try(Cursor cursor=dm.query(new DownloadManager.Query().setFilterById(downloadID))){
|
||||
if(cursor.moveToFirst()){
|
||||
long loaded=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
|
||||
long total=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
|
||||
// Log.d(TAG, "getDownloadProgress: "+loaded+" of "+total);
|
||||
return total>0 ? (float)loaded/total : 0f;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelDownload(){
|
||||
if(state!=UpdateState.DOWNLOADING)
|
||||
throw new IllegalStateException();
|
||||
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
|
||||
dm.remove(downloadID);
|
||||
downloadID=0;
|
||||
getPrefs().edit().remove("downloadID").apply();
|
||||
setState(UpdateState.UPDATE_AVAILABLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleIntentFromInstaller(Intent intent, Activity activity){
|
||||
int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0);
|
||||
if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){
|
||||
Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT);
|
||||
activity.startActivity(confirmIntent);
|
||||
}else if(status!=PackageInstaller.STATUS_SUCCESS){
|
||||
String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
||||
Toast.makeText(activity, activity.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
/*public static class InstallerStatusReceiver extends BroadcastReceiver{
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent){
|
||||
int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0);
|
||||
if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){
|
||||
Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT);
|
||||
context.startActivity(confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
|
||||
}else if(status!=PackageInstaller.STATUS_SUCCESS){
|
||||
String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
||||
Toast.makeText(context, context.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class AfterUpdateRestartReceiver extends BroadcastReceiver{
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent){
|
||||
if(Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())){
|
||||
context.getPackageManager().setComponentEnabledSetting(new ComponentName(context, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
|
||||
Toast.makeText(context, R.string.update_installed, Toast.LENGTH_SHORT).show();
|
||||
Intent restartIntent=new Intent(context, MainActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.setPackage(context.getPackageName());
|
||||
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.P){
|
||||
context.startActivity(restartIntent);
|
||||
}else{
|
||||
// Bypass activity starting restrictions by starting it from a notification
|
||||
NotificationManager nm=context.getSystemService(NotificationManager.class);
|
||||
NotificationChannel chan=new NotificationChannel("selfUpdateRestart", context.getString(R.string.update_installed), NotificationManager.IMPORTANCE_HIGH);
|
||||
nm.createNotificationChannel(chan);
|
||||
Notification n=new Notification.Builder(context, "selfUpdateRestart")
|
||||
.setContentTitle(context.getString(R.string.update_installed))
|
||||
.setContentIntent(PendingIntent.getActivity(context, 1, restartIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE))
|
||||
.setFullScreenIntent(PendingIntent.getActivity(context, 1, restartIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE), true)
|
||||
.setSmallIcon(R.drawable.ic_ntf_logo)
|
||||
.build();
|
||||
nm.notify(1, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.joinmastodon.android.updater;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class SelfUpdateContentProvider extends ContentProvider{
|
||||
@Override
|
||||
public boolean onCreate(){
|
||||
return true;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder){
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getType(@NonNull Uri uri){
|
||||
if(isCorrectUri(uri))
|
||||
return "application/vnd.android.package-archive";
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values){
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs){
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs){
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException{
|
||||
if(isCorrectUri(uri)){
|
||||
return ParcelFileDescriptor.open(((GithubSelfUpdaterImpl)GithubSelfUpdater.getInstance()).getUpdateApkFile(), ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
throw new FileNotFoundException();
|
||||
}
|
||||
|
||||
private boolean isCorrectUri(Uri uri){
|
||||
return "/update.apk".equals(uri.getPath());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
|
||||
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
|
||||
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature"/>
|
||||
|
||||
@@ -15,6 +17,7 @@
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/Theme.Mastodon.AutoLightDark"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 48 KiB |
@@ -7,9 +7,15 @@ public class GlobalUserPreferences{
|
||||
public static boolean playGifs;
|
||||
public static boolean useCustomTabs;
|
||||
public static boolean trueBlackTheme;
|
||||
public static boolean showReplies;
|
||||
public static boolean showBoosts;
|
||||
public static boolean loadNewPosts;
|
||||
public static boolean showInteractionCounts;
|
||||
public static boolean alwaysExpandContentWarnings;
|
||||
public static boolean disableMarquee;
|
||||
public static ThemePreference theme;
|
||||
|
||||
private static SharedPreferences getPrefs(){
|
||||
private static SharedPreferences getPrefs(){
|
||||
return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
@@ -18,6 +24,12 @@ public class GlobalUserPreferences{
|
||||
playGifs=prefs.getBoolean("playGifs", true);
|
||||
useCustomTabs=prefs.getBoolean("useCustomTabs", true);
|
||||
trueBlackTheme=prefs.getBoolean("trueBlackTheme", false);
|
||||
showReplies=prefs.getBoolean("showReplies", true);
|
||||
showBoosts=prefs.getBoolean("showBoosts", true);
|
||||
loadNewPosts=prefs.getBoolean("loadNewPosts", true);
|
||||
showInteractionCounts=prefs.getBoolean("showInteractionCounts", false);
|
||||
alwaysExpandContentWarnings=prefs.getBoolean("alwaysExpandContentWarnings", false);
|
||||
disableMarquee=prefs.getBoolean("disableMarquee", false);
|
||||
theme=ThemePreference.values()[prefs.getInt("theme", 0)];
|
||||
}
|
||||
|
||||
@@ -25,7 +37,13 @@ public class GlobalUserPreferences{
|
||||
getPrefs().edit()
|
||||
.putBoolean("playGifs", playGifs)
|
||||
.putBoolean("useCustomTabs", useCustomTabs)
|
||||
.putBoolean("showReplies", showReplies)
|
||||
.putBoolean("showBoosts", showBoosts)
|
||||
.putBoolean("loadNewPosts", loadNewPosts)
|
||||
.putBoolean("trueBlackTheme", trueBlackTheme)
|
||||
.putBoolean("showInteractionCounts", showInteractionCounts)
|
||||
.putBoolean("alwaysExpandContentWarnings", alwaysExpandContentWarnings)
|
||||
.putBoolean("disableMarquee", disableMarquee)
|
||||
.putInt("theme", theme.ordinal())
|
||||
.apply();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package org.joinmastodon.android;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Application;
|
||||
import android.app.Fragment;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageInstaller;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
@@ -17,6 +21,7 @@ import org.joinmastodon.android.fragments.ThreadFragment;
|
||||
import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment;
|
||||
import org.joinmastodon.android.model.Notification;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.updater.GithubSelfUpdater;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
@@ -59,6 +64,8 @@ public class MainActivity extends FragmentStackActivity{
|
||||
showFragmentForNotification(notification, session.getID());
|
||||
}else if(intent.getBooleanExtra("compose", false)){
|
||||
showCompose();
|
||||
}else{
|
||||
maybeRequestNotificationsPermission();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +75,8 @@ public class MainActivity extends FragmentStackActivity{
|
||||
try{
|
||||
Class.forName("org.joinmastodon.android.AppCenterWrapper").getMethod("init", Application.class).invoke(null, getApplication());
|
||||
}catch(ClassNotFoundException|NoSuchMethodException|IllegalAccessException|InvocationTargetException ignore){}
|
||||
}else if(GithubSelfUpdater.needSelfUpdating()){
|
||||
GithubSelfUpdater.getInstance().maybeCheckForUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +105,9 @@ public class MainActivity extends FragmentStackActivity{
|
||||
}
|
||||
}else if(intent.getBooleanExtra("compose", false)){
|
||||
showCompose();
|
||||
}
|
||||
}/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){
|
||||
GithubSelfUpdater.getInstance().handleIntentFromInstaller(intent, this);
|
||||
}*/
|
||||
}
|
||||
|
||||
private void showFragmentForNotification(Notification notification, String accountID){
|
||||
@@ -131,4 +142,10 @@ public class MainActivity extends FragmentStackActivity{
|
||||
compose.setArguments(composeArgs);
|
||||
showFragment(compose);
|
||||
}
|
||||
|
||||
private void maybeRequestNotificationsPermission(){
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){
|
||||
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import me.grishka.appkit.utils.WorkerThread;
|
||||
|
||||
public class CacheController{
|
||||
private static final String TAG="CacheController";
|
||||
private static final int DB_VERSION=2;
|
||||
private static final int DB_VERSION=3;
|
||||
private static final WorkerThread databaseThread=new WorkerThread("databaseThread");
|
||||
private static final Handler uiHandler=new Handler(Looper.getMainLooper());
|
||||
|
||||
@@ -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);
|
||||
@@ -102,7 +102,7 @@ public class CacheController{
|
||||
.exec(accountID);
|
||||
}catch(SQLiteException x){
|
||||
Log.w(TAG, x);
|
||||
uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500)));
|
||||
uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x)));
|
||||
}finally{
|
||||
closeDelayed();
|
||||
}
|
||||
@@ -126,14 +126,15 @@ public class CacheController{
|
||||
});
|
||||
}
|
||||
|
||||
public void getNotifications(String maxID, int count, boolean onlyMentions, boolean forceReload, Callback<PaginatedResponse<List<Notification>>> callback){
|
||||
public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback<PaginatedResponse<List<Notification>>> callback){
|
||||
cancelDelayedClose();
|
||||
databaseThread.postRunnable(()->{
|
||||
try{
|
||||
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.NOTIFICATIONS)).collect(Collectors.toList());
|
||||
if(!forceReload){
|
||||
SQLiteDatabase db=getOrOpenDatabase();
|
||||
try(Cursor cursor=db.query(onlyMentions ? "notifications_mentions" : "notifications_all", new String[]{"json"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){
|
||||
String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all";
|
||||
try(Cursor cursor=db.query(table, new String[]{"json"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){
|
||||
if(cursor.getCount()==count){
|
||||
ArrayList<Notification> result=new ArrayList<>();
|
||||
cursor.moveToFirst();
|
||||
@@ -145,7 +146,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;
|
||||
}
|
||||
}
|
||||
@@ -159,21 +160,21 @@ public class CacheController{
|
||||
Log.w(TAG, "getNotifications: corrupted notification object in database", x);
|
||||
}
|
||||
}
|
||||
new GetNotifications(maxID, count, onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class))
|
||||
new GetNotifications(maxID, count, onlyPosts ? EnumSet.of(Notification.Type.STATUS) : onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class))
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(List<Notification> result){
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id));
|
||||
putNotifications(result, onlyMentions, maxID==null);
|
||||
putNotifications(result, onlyMentions, onlyPosts, maxID==null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -184,16 +185,16 @@ public class CacheController{
|
||||
.exec(accountID);
|
||||
}catch(SQLiteException x){
|
||||
Log.w(TAG, x);
|
||||
uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500)));
|
||||
uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x)));
|
||||
}finally{
|
||||
closeDelayed();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private void putNotifications(List<Notification> notifications, boolean onlyMentions, boolean clear){
|
||||
private void putNotifications(List<Notification> notifications, boolean onlyMentions, boolean onlyPosts, boolean clear){
|
||||
runOnDbThread((db)->{
|
||||
String table=onlyMentions ? "notifications_mentions" : "notifications_all";
|
||||
String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all";
|
||||
if(clear)
|
||||
db.delete(table, null, null);
|
||||
ContentValues values=new ContentValues(3);
|
||||
@@ -317,6 +318,7 @@ public class CacheController{
|
||||
`type` INTEGER NOT NULL
|
||||
)""");
|
||||
createRecentSearchesTable(db);
|
||||
createPostsNotificationsTable(db);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -324,6 +326,9 @@ public class CacheController{
|
||||
if(oldVersion==1){
|
||||
createRecentSearchesTable(db);
|
||||
}
|
||||
if(oldVersion==2){
|
||||
createPostsNotificationsTable(db);
|
||||
}
|
||||
}
|
||||
|
||||
private void createRecentSearchesTable(SQLiteDatabase db){
|
||||
@@ -334,6 +339,16 @@ public class CacheController{
|
||||
`time` INTEGER NOT NULL
|
||||
)""");
|
||||
}
|
||||
|
||||
private void createPostsNotificationsTable(SQLiteDatabase db){
|
||||
db.execSQL("""
|
||||
CREATE TABLE `notifications_posts` (
|
||||
`id` VARCHAR(25) NOT NULL PRIMARY KEY,
|
||||
`json` TEXT NOT NULL,
|
||||
`flags` INTEGER NOT NULL DEFAULT 0,
|
||||
`type` INTEGER NOT NULL
|
||||
)""");
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
|
||||
@@ -96,11 +96,11 @@ public class MastodonAPIController{
|
||||
if(call.isCanceled())
|
||||
return;
|
||||
if(BuildConfig.DEBUG)
|
||||
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed: "+e);
|
||||
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed", e);
|
||||
synchronized(req){
|
||||
req.okhttpCall=null;
|
||||
}
|
||||
req.onError(e.getLocalizedMessage(), 0);
|
||||
req.onError(e.getLocalizedMessage(), 0, e);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -133,7 +133,7 @@ public class MastodonAPIController{
|
||||
}catch(JsonIOException|JsonSyntaxException x){
|
||||
if(BuildConfig.DEBUG)
|
||||
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x);
|
||||
req.onError(x.getLocalizedMessage(), response.code());
|
||||
req.onError(x.getLocalizedMessage(), response.code(), x);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ public class MastodonAPIController{
|
||||
}catch(IOException x){
|
||||
if(BuildConfig.DEBUG)
|
||||
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x);
|
||||
req.onError(x.getLocalizedMessage(), response.code());
|
||||
req.onError(x.getLocalizedMessage(), response.code(), x);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ public class MastodonAPIController{
|
||||
JsonObject error=JsonParser.parseReader(reader).getAsJsonObject();
|
||||
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" received error: "+error);
|
||||
if(error.has("details")){
|
||||
MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code());
|
||||
MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null);
|
||||
HashMap<String, List<MastodonDetailedErrorResponse.FieldError>> details=new HashMap<>();
|
||||
JsonObject errorDetails=error.getAsJsonObject("details");
|
||||
for(String key:errorDetails.keySet()){
|
||||
@@ -172,12 +172,12 @@ public class MastodonAPIController{
|
||||
err.detailedErrors=details;
|
||||
req.onError(err);
|
||||
}else{
|
||||
req.onError(error.get("error").getAsString(), response.code());
|
||||
req.onError(error.get("error").getAsString(), response.code(), null);
|
||||
}
|
||||
}catch(JsonIOException|JsonSyntaxException x){
|
||||
req.onError(response.code()+" "+response.message(), response.code());
|
||||
req.onError(response.code()+" "+response.message(), response.code(), x);
|
||||
}catch(Exception x){
|
||||
req.onError("Error parsing an API error", response.code());
|
||||
req.onError("Error parsing an API error", response.code(), x);
|
||||
}
|
||||
}
|
||||
}catch(Exception x){
|
||||
@@ -189,7 +189,7 @@ public class MastodonAPIController{
|
||||
}catch(Exception x){
|
||||
if(BuildConfig.DEBUG)
|
||||
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x);
|
||||
req.onError(x.getLocalizedMessage(), 0);
|
||||
req.onError(x.getLocalizedMessage(), 0, x);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
@@ -197,4 +197,8 @@ public class MastodonAPIController{
|
||||
public static void runInBackground(Runnable action){
|
||||
thread.postRunnable(action, 0);
|
||||
}
|
||||
|
||||
public static OkHttpClient getHttpClient(){
|
||||
return httpClient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
|
||||
account.getApiController().submitRequest(this);
|
||||
}catch(Exception x){
|
||||
Log.e(TAG, "exec: this shouldn't happen, but it still did", x);
|
||||
invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1));
|
||||
invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1, x));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
@@ -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){
|
||||
invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus));
|
||||
void onError(String msg, int httpStatus, Throwable exception){
|
||||
if(!canceled)
|
||||
invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus, exception));
|
||||
}
|
||||
|
||||
void onSuccess(T resp){
|
||||
invokeSuccessCallback(resp);
|
||||
if(!canceled)
|
||||
invokeSuccessCallback(resp);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -7,8 +7,8 @@ import java.util.Map;
|
||||
public class MastodonDetailedErrorResponse extends MastodonErrorResponse{
|
||||
public Map<String, List<FieldError>> detailedErrors;
|
||||
|
||||
public MastodonDetailedErrorResponse(String error, int httpStatus){
|
||||
super(error, httpStatus);
|
||||
public MastodonDetailedErrorResponse(String error, int httpStatus, Throwable exception){
|
||||
super(error, httpStatus, exception);
|
||||
}
|
||||
|
||||
public static class FieldError{
|
||||
|
||||
@@ -12,10 +12,12 @@ import me.grishka.appkit.api.ErrorResponse;
|
||||
public class MastodonErrorResponse extends ErrorResponse{
|
||||
public final String error;
|
||||
public final int httpStatus;
|
||||
public final Throwable underlyingException;
|
||||
|
||||
public MastodonErrorResponse(String error, int httpStatus){
|
||||
public MastodonErrorResponse(String error, int httpStatus, Throwable exception){
|
||||
this.error=error;
|
||||
this.httpStatus=httpStatus;
|
||||
this.underlyingException=exception;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.media.ExifInterface;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
@@ -48,6 +49,8 @@ public class ResizedImageRequestBody extends CountingRequestBody{
|
||||
}
|
||||
contentType=MastodonApp.context.getContentResolver().getType(uri);
|
||||
}
|
||||
if(TextUtils.isEmpty(contentType))
|
||||
contentType="image/jpeg";
|
||||
if(needResize(opts.outWidth, opts.outHeight) || needCrop(opts.outWidth, opts.outHeight)){
|
||||
Bitmap bitmap;
|
||||
if(Build.VERSION.SDK_INT>=28){
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.joinmastodon.android.api.requests.accounts;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
|
||||
public class AuthorizeFollowRequest extends MastodonAPIRequest<Relationship>{
|
||||
public AuthorizeFollowRequest(String id){
|
||||
super(HttpMethod.POST, "/follow_requests/"+id+"/authorize", Relationship.class);
|
||||
setRequestBody(new Object());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.joinmastodon.android.api.requests.accounts;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.Response;
|
||||
|
||||
public class GetFavourites extends MastodonAPIRequest<List<Status>>{
|
||||
private String maxId;
|
||||
|
||||
public GetFavourites(String maxID, String minID, int limit){
|
||||
super(HttpMethod.GET, "/favourites", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
if(minID!=null)
|
||||
addQueryParameter("min_id", minID);
|
||||
if(limit>0)
|
||||
addQueryParameter("limit", ""+limit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateAndPostprocessResponse(List<Status> respObj, Response httpResponse) throws IOException {
|
||||
super.validateAndPostprocessResponse(respObj, httpResponse);
|
||||
// <https://mastodon.social/api/v1/bookmarks?max_id=268962>; rel="next",
|
||||
// <https://mastodon.social/api/v1/bookmarks?min_id=268981>; rel="prev"
|
||||
String link=httpResponse.header("link");
|
||||
// parsing link header by hand; using a library would be cleaner
|
||||
// (also, the functionality should be part of the max id logics and implemented in MastodonAPIRequest)
|
||||
if(link==null) return;
|
||||
String maxIdEq="max_id=";
|
||||
for(String s : link.split(",")) {
|
||||
if(s.contains("rel=\"next\"")) {
|
||||
int start=s.indexOf(maxIdEq)+maxIdEq.length();
|
||||
int end=s.indexOf('>');
|
||||
if(start<0 || start>end) return;
|
||||
this.maxId=s.substring(start, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String getMaxId() {
|
||||
return maxId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.joinmastodon.android.api.requests.accounts;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.FollowSuggestion;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.Response;
|
||||
|
||||
public class GetFollowRequests extends MastodonAPIRequest<List<Account>>{
|
||||
private String maxId;
|
||||
|
||||
public GetFollowRequests(String maxID, String minID, int limit){
|
||||
super(HttpMethod.GET, "/follow_requests", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
if(minID!=null)
|
||||
addQueryParameter("min_id", minID);
|
||||
if(limit>0)
|
||||
addQueryParameter("limit", ""+limit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateAndPostprocessResponse(List<Account> respObj, Response httpResponse) throws IOException {
|
||||
super.validateAndPostprocessResponse(respObj, httpResponse);
|
||||
// <https://mastodon.social/api/v1/follow_requests?max_id=268962>; rel="next",
|
||||
// <https://mastodon.social/api/v1/follow_requests?min_id=268981>; rel="prev"
|
||||
String link=httpResponse.header("link");
|
||||
// parsing link header by hand; using a library would be cleaner
|
||||
// (also, the functionality should be part of the max id logics and implemented in MastodonAPIRequest)
|
||||
if(link==null) return;
|
||||
String maxIdEq="max_id=";
|
||||
for(String s : link.split(",")) {
|
||||
if(s.contains("rel=\"next\"")) {
|
||||
int start=s.indexOf(maxIdEq)+maxIdEq.length();
|
||||
int end=s.indexOf('>');
|
||||
if(start<0 || start>end) return;
|
||||
this.maxId=s.substring(start, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String getMaxId() {
|
||||
return maxId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.joinmastodon.android.api.requests.accounts;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Preferences;
|
||||
|
||||
public class GetPreferences extends MastodonAPIRequest<Preferences> {
|
||||
public GetPreferences(){
|
||||
super(HttpMethod.GET, "/preferences", Preferences.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.joinmastodon.android.api.requests.accounts;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
|
||||
public class RejectFollowRequest extends MastodonAPIRequest<Relationship>{
|
||||
public RejectFollowRequest(String id){
|
||||
super(HttpMethod.POST, "/follow_requests/"+id+"/reject", Relationship.class);
|
||||
setRequestBody(new Object());
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
|
||||
public class SetAccountFollowed extends MastodonAPIRequest<Relationship>{
|
||||
public SetAccountFollowed(String id, boolean followed, boolean showReblogs){
|
||||
public SetAccountFollowed(String id, boolean followed, boolean showReblogs, boolean notify){
|
||||
super(HttpMethod.POST, "/accounts/"+id+"/"+(followed ? "follow" : "unfollow"), Relationship.class);
|
||||
if(followed)
|
||||
setRequestBody(new Request(showReblogs, null));
|
||||
setRequestBody(new Request(showReblogs, notify));
|
||||
else
|
||||
setRequestBody(new Object());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.joinmastodon.android.api.requests.lists;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import java.util.List;
|
||||
|
||||
public class AddAccountsToList extends MastodonAPIRequest<Object> {
|
||||
public AddAccountsToList(String listId, List<String> accountIds){
|
||||
super(HttpMethod.POST, "/lists/"+listId+"/accounts", Object.class);
|
||||
Request req = new Request();
|
||||
req.accountIds = accountIds;
|
||||
setRequestBody(req);
|
||||
}
|
||||
|
||||
public static class Request{
|
||||
public List<String> accountIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.joinmastodon.android.api.requests.lists;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.ListTimeline;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class GetLists extends MastodonAPIRequest<List<ListTimeline>>{
|
||||
public GetLists() {
|
||||
super(HttpMethod.GET, "/lists", new TypeToken<>(){});
|
||||
}
|
||||
public GetLists(String accountID) {
|
||||
super(HttpMethod.GET, "/accounts/"+accountID+"/lists", new TypeToken<>(){});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.joinmastodon.android.api.requests.lists;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import java.util.List;
|
||||
|
||||
public class RemoveAccountsFromList extends MastodonAPIRequest<Object> {
|
||||
public RemoveAccountsFromList(String listId, List<String> accountIds){
|
||||
super(HttpMethod.DELETE, "/lists/"+listId+"/accounts", Object.class);
|
||||
Request req = new Request();
|
||||
req.accountIds = accountIds;
|
||||
setRequestBody(req);
|
||||
}
|
||||
|
||||
public static class Request{
|
||||
public List<String> accountIds;
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ public class CreateOAuthApp extends MastodonAPIRequest<Application>{
|
||||
}
|
||||
|
||||
private static class Request{
|
||||
public String clientName="Mastodon for Android Fork";
|
||||
public String clientName="Mastodos";
|
||||
public String redirectUris=AccountSessionManager.REDIRECT_URI;
|
||||
public String scopes=AccountSessionManager.SCOPE;
|
||||
public String website="https://github.com/sk22/mastodon-android-fork";
|
||||
public String website="https://sk22.github.io/mastodos";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.joinmastodon.android.api.requests.statuses;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Attachment;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import okhttp3.Response;
|
||||
|
||||
public class GetAttachmentByID extends MastodonAPIRequest<Attachment>{
|
||||
public GetAttachmentByID(String id){
|
||||
super(HttpMethod.GET, "/media/"+id, Attachment.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateAndPostprocessResponse(Attachment respObj, Response httpResponse) throws IOException{
|
||||
if(httpResponse.code()==206)
|
||||
respObj.url="";
|
||||
super.validateAndPostprocessResponse(respObj, httpResponse);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import java.io.IOException;
|
||||
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
public class UploadAttachment extends MastodonAPIRequest<Attachment>{
|
||||
private Uri uri;
|
||||
@@ -40,6 +41,18 @@ public class UploadAttachment extends MastodonAPIRequest<Attachment>{
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getPathPrefix(){
|
||||
return "/api/v2";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateAndPostprocessResponse(Attachment respObj, Response httpResponse) throws IOException{
|
||||
if(respObj.url==null)
|
||||
respObj.url="";
|
||||
super.validateAndPostprocessResponse(respObj, httpResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RequestBody getRequestBody() throws IOException{
|
||||
MultipartBody.Builder builder=new MultipartBody.Builder()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.joinmastodon.android.api.requests.tags;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Hashtag;
|
||||
|
||||
public class GetHashtag extends MastodonAPIRequest<Hashtag> {
|
||||
public GetHashtag(String name){
|
||||
super(HttpMethod.GET, "/tags/"+name, Hashtag.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.joinmastodon.android.api.requests.tags;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Hashtag;
|
||||
|
||||
public class SetHashtagFollowed extends MastodonAPIRequest<Hashtag>{
|
||||
public SetHashtagFollowed(String name, boolean followed){
|
||||
super(HttpMethod.POST, "/tags/"+name+"/"+(followed ? "follow" : "unfollow"), Hashtag.class);
|
||||
setRequestBody(new Object());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.joinmastodon.android.api.requests.timelines;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class GetListTimeline extends MastodonAPIRequest<List<Status>> {
|
||||
public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID) {
|
||||
super(HttpMethod.GET, "/timelines/list/"+listID, new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
if(minID!=null)
|
||||
addQueryParameter("min_id", minID);
|
||||
if(limit>0)
|
||||
addQueryParameter("limit", ""+limit);
|
||||
if(sinceID!=null)
|
||||
addQueryParameter("since_id", sinceID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.joinmastodon.android.events;
|
||||
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
|
||||
public class FollowRequestHandledEvent {
|
||||
public String accountID;
|
||||
public boolean accepted;
|
||||
public Account account;
|
||||
public Relationship relationship;
|
||||
|
||||
public FollowRequestHandledEvent(String accountID, boolean accepted, Account account, Relationship rel){
|
||||
this.accountID=accountID;
|
||||
this.accepted=accepted;
|
||||
this.account=account;
|
||||
this.relationship=rel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.joinmastodon.android.events;
|
||||
|
||||
public class NotificationDeletedEvent{
|
||||
public final String id;
|
||||
|
||||
public NotificationDeletedEvent(String id){
|
||||
this.id=id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.joinmastodon.android.events;
|
||||
|
||||
import org.joinmastodon.android.updater.GithubSelfUpdater;
|
||||
|
||||
public class SelfUpdateStateChangedEvent{
|
||||
public final GithubSelfUpdater.UpdateState state;
|
||||
|
||||
public SelfUpdateStateChangedEvent(GithubSelfUpdater.UpdateState state){
|
||||
this.state=state;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import org.joinmastodon.android.model.Status;
|
||||
|
||||
public class StatusCountersUpdatedEvent{
|
||||
public String id;
|
||||
public int favorites, reblogs, replies;
|
||||
public long favorites, reblogs, replies;
|
||||
public boolean favorited, reblogged, pinned;
|
||||
|
||||
public StatusCountersUpdatedEvent(Status s){
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.view.WindowInsets;
|
||||
import android.widget.Toolbar;
|
||||
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
|
||||
import org.joinmastodon.android.api.requests.polls.SubmitPollVote;
|
||||
@@ -81,6 +82,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
if(GlobalUserPreferences.disableMarquee){
|
||||
setTitleMarqueeEnabled(false);
|
||||
setSubtitleMarqueeEnabled(false);
|
||||
}
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
|
||||
setRetainInstance(true);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,19 @@ import android.animation.ObjectAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.ClipData;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Outline;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.RenderEffect;
|
||||
import android.graphics.Shader;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.LayerDrawable;
|
||||
import android.icu.text.BreakIterator;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
@@ -52,9 +58,13 @@ import com.twitter.twittertext.TwitterTextEmojiRegex;
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.MastodonAPIController;
|
||||
import org.joinmastodon.android.api.MastodonErrorResponse;
|
||||
import org.joinmastodon.android.api.ProgressListener;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetPreferences;
|
||||
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
|
||||
import org.joinmastodon.android.api.requests.statuses.EditStatus;
|
||||
import org.joinmastodon.android.api.requests.statuses.GetAttachmentByID;
|
||||
import org.joinmastodon.android.api.requests.statuses.UploadAttachment;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
@@ -68,6 +78,7 @@ import org.joinmastodon.android.model.EmojiCategory;
|
||||
import org.joinmastodon.android.model.Instance;
|
||||
import org.joinmastodon.android.model.Mention;
|
||||
import org.joinmastodon.android.model.Poll;
|
||||
import org.joinmastodon.android.model.Preferences;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.model.StatusPrivacy;
|
||||
import org.joinmastodon.android.ui.ComposeAutocompleteViewController;
|
||||
@@ -79,6 +90,7 @@ import org.joinmastodon.android.ui.text.ComposeAutocompleteSpan;
|
||||
import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
|
||||
import org.joinmastodon.android.ui.utils.TransferSpeedTracker;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.views.ComposeEditText;
|
||||
import org.joinmastodon.android.ui.views.ComposeMediaLayout;
|
||||
@@ -87,6 +99,11 @@ import org.joinmastodon.android.ui.views.SizeListenerLinearLayout;
|
||||
import org.parceler.Parcel;
|
||||
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;
|
||||
import java.util.Locale;
|
||||
@@ -108,6 +125,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private static final int MEDIA_RESULT=717;
|
||||
private static final int IMAGE_DESCRIPTION_RESULT=363;
|
||||
private static final int MAX_ATTACHMENTS=4;
|
||||
private static final String TAG="ComposeFragment";
|
||||
|
||||
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
@@ -146,17 +164,18 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
|
||||
private Button publishButton;
|
||||
private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, visibilityBtn;
|
||||
private ImageView sensitiveIcon;
|
||||
private ComposeMediaLayout attachmentsView;
|
||||
private TextView replyText;
|
||||
private ReorderableLinearLayout pollOptionsView;
|
||||
private View pollWrap;
|
||||
private View addPollOptionBtn;
|
||||
private View sensitiveItem;
|
||||
private TextView pollDurationView;
|
||||
|
||||
private ArrayList<DraftPollOption> pollOptions=new ArrayList<>();
|
||||
|
||||
private ArrayList<DraftMediaAttachment> queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>(), allAttachments=new ArrayList<>();
|
||||
private DraftMediaAttachment uploadingAttachment;
|
||||
private ArrayList<DraftMediaAttachment> attachments=new ArrayList<>();
|
||||
|
||||
private List<EmojiCategory> customEmojis;
|
||||
private CustomEmojiPopupKeyboard emojiKeyboard;
|
||||
@@ -167,11 +186,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private String pollDurationStr;
|
||||
private EditText spoilerEdit;
|
||||
private boolean hasSpoiler;
|
||||
private boolean sensitive;
|
||||
private ProgressBar sendProgress;
|
||||
private ImageView sendError;
|
||||
private View sendingOverlay;
|
||||
private WindowManager wm;
|
||||
private StatusPrivacy statusVisibility=StatusPrivacy.UNLISTED;
|
||||
private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC;
|
||||
private ComposeAutocompleteSpan currentAutocompleteSpan;
|
||||
private FrameLayout mainEditTextWrap;
|
||||
private ComposeAutocompleteViewController autocompleteViewController;
|
||||
@@ -179,16 +199,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private boolean attachmentsErrorShowing;
|
||||
|
||||
private Status editingStatus;
|
||||
private boolean redraftStatus;
|
||||
private boolean pollChanged;
|
||||
private boolean creatingView;
|
||||
|
||||
public static DraftMediaAttachment redraftAttachment(Attachment att) {
|
||||
DraftMediaAttachment draft=new DraftMediaAttachment();
|
||||
draft.serverAttachment=att;
|
||||
draft.description=att.description;
|
||||
draft.uri=Uri.parse(att.url);
|
||||
return draft;
|
||||
}
|
||||
private boolean ignoreSelectionChanges=false;
|
||||
private Runnable updateUploadEtaRunnable;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
@@ -203,6 +218,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
instance=AccountSessionManager.getInstance().getInstanceInfo(instanceDomain);
|
||||
if(getArguments().containsKey("editStatus")){
|
||||
editingStatus=Parcels.unwrap(getArguments().getParcelable("editStatus"));
|
||||
redraftStatus=getArguments().getBoolean("redraftStatus");
|
||||
}
|
||||
if(instance==null){
|
||||
Nav.finish(this);
|
||||
@@ -219,25 +235,20 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
else
|
||||
charLimit=500;
|
||||
|
||||
if(getArguments().containsKey("replyTo")){
|
||||
replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo"));
|
||||
statusVisibility=replyTo.visibility;
|
||||
}
|
||||
|
||||
if(getArguments().containsKey("visibility")){
|
||||
statusVisibility=(StatusPrivacy) getArguments().getSerializable("visibility");
|
||||
}
|
||||
|
||||
if(savedInstanceState!=null){
|
||||
statusVisibility=(StatusPrivacy) savedInstanceState.getSerializable("visibility");
|
||||
}
|
||||
loadDefaultStatusVisibility(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy(){
|
||||
super.onDestroy();
|
||||
if(uploadingAttachment!=null && uploadingAttachment.uploadRequest!=null)
|
||||
uploadingAttachment.uploadRequest.cancel();
|
||||
for(DraftMediaAttachment att:attachments){
|
||||
if(att.isUploadingOrProcessing())
|
||||
att.cancelUpload();
|
||||
}
|
||||
if(updateUploadEtaRunnable!=null){
|
||||
UiUtils.removeCallbacks(updateUploadEtaRunnable);
|
||||
updateUploadEtaRunnable=null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -279,6 +290,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
emojiBtn=view.findViewById(R.id.btn_emoji);
|
||||
spoilerBtn=view.findViewById(R.id.btn_spoiler);
|
||||
visibilityBtn=view.findViewById(R.id.btn_visibility);
|
||||
sensitiveIcon=view.findViewById(R.id.sensitive_icon);
|
||||
sensitiveItem=view.findViewById(R.id.sensitive_item);
|
||||
replyText=view.findViewById(R.id.reply_text);
|
||||
|
||||
mediaBtn.setOnClickListener(v->openFilePicker());
|
||||
@@ -286,6 +299,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText));
|
||||
spoilerBtn.setOnClickListener(v->toggleSpoiler());
|
||||
visibilityBtn.setOnClickListener(this::onVisibilityClick);
|
||||
sensitiveItem.setOnClickListener(v->toggleSensitive());
|
||||
emojiKeyboard.setOnIconChangedListener(new PopupKeyboard.OnIconChangeListener(){
|
||||
@Override
|
||||
public void onIconChanged(int icon){
|
||||
@@ -311,18 +325,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
pollDurationView.setOnClickListener(v->showPollDurationMenu());
|
||||
|
||||
pollOptions.clear();
|
||||
ArrayList<String> restoredPollOptions=(savedInstanceState!=null ? savedInstanceState : getArguments())
|
||||
.getStringArrayList("pollOptions");
|
||||
if(restoredPollOptions!=null){
|
||||
if(savedInstanceState==null){
|
||||
// restoring from arguments
|
||||
pollDuration=getArguments().getInt("pollDuration");
|
||||
pollDurationStr=DateUtils.formatElapsedTime(pollDuration); // getResources().getQuantityString(R.plurals.x_hours, pollDuration/3600);
|
||||
}
|
||||
if(savedInstanceState!=null && savedInstanceState.containsKey("pollOptions")){
|
||||
pollBtn.setSelected(true);
|
||||
mediaBtn.setEnabled(false);
|
||||
pollWrap.setVisibility(View.VISIBLE);
|
||||
for(String oldText:restoredPollOptions){
|
||||
for(String oldText:savedInstanceState.getStringArrayList("pollOptions")){
|
||||
DraftPollOption opt=createDraftPollOption();
|
||||
opt.edit.setText(oldText);
|
||||
}
|
||||
@@ -336,6 +343,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
DraftPollOption opt=createDraftPollOption();
|
||||
opt.edit.setText(eopt.title);
|
||||
}
|
||||
pollDuration=(int)editingStatus.poll.expiresAt.minus(System.currentTimeMillis(), ChronoUnit.MILLIS).getEpochSecond();
|
||||
pollDurationStr=UiUtils.formatTimeLeft(getActivity(), editingStatus.poll.expiresAt);
|
||||
updatePollOptionHints();
|
||||
pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr));
|
||||
}else{
|
||||
@@ -351,26 +360,31 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
spoilerEdit.setVisibility(View.VISIBLE);
|
||||
spoilerBtn.setSelected(true);
|
||||
}else if(editingStatus!=null && !TextUtils.isEmpty(editingStatus.spoilerText)){
|
||||
hasSpoiler=true;
|
||||
spoilerEdit.setVisibility(View.VISIBLE);
|
||||
spoilerEdit.setText(getArguments().getString("sourceSpoiler", editingStatus.spoilerText));
|
||||
spoilerBtn.setSelected(true);
|
||||
}
|
||||
|
||||
ArrayList<Parcelable> serializedAttachments=(savedInstanceState!=null ? savedInstanceState : getArguments())
|
||||
.getParcelableArrayList("attachments");
|
||||
if(serializedAttachments!=null){
|
||||
if(savedInstanceState!=null && savedInstanceState.containsKey("attachments")){
|
||||
ArrayList<Parcelable> serializedAttachments=savedInstanceState.getParcelableArrayList("attachments");
|
||||
for(Parcelable a:serializedAttachments){
|
||||
DraftMediaAttachment att=Parcels.unwrap(a);
|
||||
attachmentsView.addView(createMediaAttachmentView(att));
|
||||
attachments.add(att);
|
||||
}
|
||||
attachmentsView.setVisibility(View.VISIBLE);
|
||||
}else if(!allAttachments.isEmpty()){
|
||||
}else if(!attachments.isEmpty()){
|
||||
attachmentsView.setVisibility(View.VISIBLE);
|
||||
for(DraftMediaAttachment att:allAttachments){
|
||||
for(DraftMediaAttachment att:attachments){
|
||||
attachmentsView.addView(createMediaAttachmentView(att));
|
||||
}
|
||||
}
|
||||
|
||||
if(editingStatus!=null && editingStatus.visibility!=null) {
|
||||
statusVisibility=editingStatus.visibility;
|
||||
}
|
||||
|
||||
updateVisibilityIcon();
|
||||
|
||||
autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID);
|
||||
@@ -395,16 +409,17 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
outState.putStringArrayList("pollOptions", opts);
|
||||
outState.putInt("pollDuration", pollDuration);
|
||||
outState.putString("pollDurationStr", pollDurationStr);
|
||||
outState.putBoolean("hasSpoiler", hasSpoiler);
|
||||
if(!attachments.isEmpty()){
|
||||
ArrayList<Parcelable> serializedAttachments=new ArrayList<>(attachments.size());
|
||||
for(DraftMediaAttachment att:attachments){
|
||||
serializedAttachments.add(Parcels.wrap(att));
|
||||
}
|
||||
outState.putParcelableArrayList("attachments", serializedAttachments);
|
||||
}
|
||||
outState.putSerializable("visibility", statusVisibility);
|
||||
}
|
||||
outState.putBoolean("sensitive", sensitive);
|
||||
outState.putBoolean("hasSpoiler", hasSpoiler);
|
||||
if(!attachments.isEmpty()){
|
||||
ArrayList<Parcelable> serializedAttachments=new ArrayList<>(attachments.size());
|
||||
for(DraftMediaAttachment att:attachments){
|
||||
serializedAttachments.add(Parcels.wrap(att));
|
||||
}
|
||||
outState.putParcelableArrayList("attachments", serializedAttachments);
|
||||
}
|
||||
outState.putSerializable("visibility", statusVisibility);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -488,6 +503,24 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
spoilerEdit.addTextChangedListener(new SimpleTextWatcher(e->updateCharCounter()));
|
||||
if(replyTo!=null){
|
||||
replyText.setText(getString(R.string.in_reply_to, replyTo.account.displayName));
|
||||
int visibilityNameRes = switch (statusVisibility) {
|
||||
case PUBLIC -> R.string.visibility_public;
|
||||
case UNLISTED -> R.string.visibility_unlisted;
|
||||
case PRIVATE -> R.string.visibility_followers_only;
|
||||
case DIRECT -> R.string.visibility_private;
|
||||
};
|
||||
replyText.setContentDescription(getString(R.string.in_reply_to, replyTo.account.displayName) + ". " + getString(R.string.post_visibility) + ": " + getString(visibilityNameRes));
|
||||
Drawable visibilityIcon = getActivity().getDrawable(switch(statusVisibility){
|
||||
case PUBLIC -> R.drawable.ic_fluent_earth_20_regular;
|
||||
case UNLISTED -> R.drawable.ic_fluent_people_community_20_regular;
|
||||
case PRIVATE -> R.drawable.ic_fluent_people_checkmark_20_regular;
|
||||
case DIRECT -> R.drawable.ic_at_symbol;
|
||||
});
|
||||
visibilityIcon.setBounds(0, 0, V.dp(20), V.dp(20));
|
||||
Drawable replyArrow = getActivity().getDrawable(R.drawable.ic_fluent_arrow_reply_20_filled);
|
||||
replyArrow.setBounds(0, 0, V.dp(20), V.dp(20));
|
||||
replyText.setCompoundDrawables(replyArrow, null, visibilityIcon, null);
|
||||
|
||||
ArrayList<String> mentions=new ArrayList<>();
|
||||
String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id;
|
||||
if(!replyTo.account.id.equals(ownID))
|
||||
@@ -502,7 +535,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
initialText=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" ";
|
||||
if(savedInstanceState==null){
|
||||
mainEditText.setText(initialText);
|
||||
ignoreSelectionChanges=true;
|
||||
mainEditText.setSelection(mainEditText.length());
|
||||
ignoreSelectionChanges=false;
|
||||
if(!TextUtils.isEmpty(replyTo.spoilerText)){
|
||||
hasSpoiler=true;
|
||||
spoilerEdit.setVisibility(View.VISIBLE);
|
||||
@@ -510,14 +545,17 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
spoilerBtn.setSelected(true);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
}else if (editingStatus==null || editingStatus.inReplyToId==null){
|
||||
// TODO: remove workaround after https://github.com/mastodon/mastodon-android/issues/341 gets fixed
|
||||
replyText.setVisibility(View.GONE);
|
||||
}
|
||||
if(savedInstanceState==null){
|
||||
if(editingStatus!=null){
|
||||
initialText=getArguments().getString("sourceText", "");
|
||||
mainEditText.setText(initialText);
|
||||
ignoreSelectionChanges=true;
|
||||
mainEditText.setSelection(mainEditText.length());
|
||||
ignoreSelectionChanges=false;
|
||||
if(!editingStatus.mediaAttachments.isEmpty()){
|
||||
attachmentsView.setVisibility(View.VISIBLE);
|
||||
for(Attachment att:editingStatus.mediaAttachments){
|
||||
@@ -525,6 +563,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
da.serverAttachment=att;
|
||||
da.description=att.description;
|
||||
da.uri=Uri.parse(att.previewUrl);
|
||||
da.state=AttachmentUploadState.DONE;
|
||||
attachmentsView.addView(createMediaAttachmentView(da));
|
||||
attachments.add(da);
|
||||
}
|
||||
@@ -534,7 +573,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
String prefilledText=getArguments().getString("prefilledText");
|
||||
if(!TextUtils.isEmpty(prefilledText)){
|
||||
mainEditText.setText(prefilledText);
|
||||
ignoreSelectionChanges=true;
|
||||
mainEditText.setSelection(mainEditText.length());
|
||||
ignoreSelectionChanges=false;
|
||||
initialText=prefilledText;
|
||||
}
|
||||
ArrayList<Uri> mediaUris=getArguments().getParcelableArrayList("mediaAttachments");
|
||||
@@ -546,15 +587,18 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
}
|
||||
}
|
||||
|
||||
updateSensitive();
|
||||
|
||||
if(editingStatus!=null){
|
||||
updateCharCounter();
|
||||
visibilityBtn.setEnabled(redraftStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
|
||||
publishButton=new Button(getActivity());
|
||||
publishButton.setText(editingStatus==null ? R.string.publish : R.string.save);
|
||||
publishButton.setText(editingStatus==null || redraftStatus ? R.string.publish : R.string.save);
|
||||
publishButton.setOnClickListener(this::onPublishClick);
|
||||
LinearLayout wrap=new LinearLayout(getActivity());
|
||||
wrap.setOrientation(LinearLayout.HORIZONTAL);
|
||||
@@ -624,8 +668,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
if(opt.edit.length()>0)
|
||||
nonEmptyPollOptionsCount++;
|
||||
}
|
||||
publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && uploadingAttachment==null && failedAttachments.isEmpty() && queuedAttachments.isEmpty()
|
||||
&& (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1));
|
||||
if(publishButton==null)
|
||||
return;
|
||||
int nonDoneAttachmentCount=0;
|
||||
for(DraftMediaAttachment att:attachments){
|
||||
if(att.state!=AttachmentUploadState.DONE)
|
||||
nonDoneAttachmentCount++;
|
||||
}
|
||||
publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1));
|
||||
}
|
||||
|
||||
private void onCustomEmojiClick(Emoji emoji){
|
||||
@@ -649,11 +699,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
CreateStatus.Request req=new CreateStatus.Request();
|
||||
req.status=text;
|
||||
req.visibility=statusVisibility;
|
||||
req.sensitive=sensitive;
|
||||
if(!attachments.isEmpty()){
|
||||
req.mediaIds=attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList());
|
||||
}
|
||||
if(replyTo!=null){
|
||||
req.inReplyToId=replyTo.id;
|
||||
if(replyTo!=null || (editingStatus != null && editingStatus.inReplyToId!=null)){
|
||||
req.inReplyToId=editingStatus!=null ? editingStatus.inReplyToId : replyTo.id;
|
||||
}
|
||||
if(!pollOptions.isEmpty()){
|
||||
req.poll=new CreateStatus.Request.Poll();
|
||||
@@ -696,6 +747,13 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
E.post(new StatusUpdatedEvent(result));
|
||||
}
|
||||
Nav.finish(ComposeFragment.this);
|
||||
if (getArguments().getBoolean("navigateToStatus", false)) {
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("status", Parcels.wrap(result));
|
||||
if(replyTo!=null) args.putParcelable("inReplyToAccount", Parcels.wrap(replyTo));
|
||||
Nav.go(getActivity(), ThreadFragment.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -709,7 +767,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
}
|
||||
};
|
||||
|
||||
if(editingStatus!=null){
|
||||
if(editingStatus!=null && !redraftStatus){
|
||||
new EditStatus(req, editingStatus.id)
|
||||
.setCallback(resCallback)
|
||||
.exec(accountID);
|
||||
@@ -721,6 +779,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
}
|
||||
|
||||
private boolean hasDraft(){
|
||||
if(getArguments().getBoolean("hasDraft", false)) return true;
|
||||
if(editingStatus!=null){
|
||||
if(!mainEditText.getText().toString().equals(initialText))
|
||||
return true;
|
||||
@@ -732,10 +791,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
boolean pollFieldsHaveContent=false;
|
||||
for(DraftPollOption opt:pollOptions)
|
||||
pollFieldsHaveContent|=opt.edit.length()>0;
|
||||
return getArguments().getBoolean("hasDraft", false)
|
||||
|| (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText))
|
||||
|| !attachments.isEmpty() || uploadingAttachment!=null || !queuedAttachments.isEmpty()
|
||||
|| !failedAttachments.isEmpty() || pollFieldsHaveContent;
|
||||
return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !attachments.isEmpty() || pollFieldsHaveContent;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -836,7 +892,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
}
|
||||
if(size>sizeLimit){
|
||||
float mb=sizeLimit/(float) (1024*1024);
|
||||
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb);
|
||||
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%.0f" : "%.2f", mb);
|
||||
showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
|
||||
return false;
|
||||
}
|
||||
@@ -845,20 +901,19 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
pollBtn.setEnabled(false);
|
||||
DraftMediaAttachment draft=new DraftMediaAttachment();
|
||||
draft.uri=uri;
|
||||
draft.mimeType=type;
|
||||
draft.description=description;
|
||||
|
||||
attachmentsView.addView(createMediaAttachmentView(draft));
|
||||
allAttachments.add(draft);
|
||||
attachments.add(draft);
|
||||
attachmentsView.setVisibility(View.VISIBLE);
|
||||
draft.overlay.setVisibility(View.VISIBLE);
|
||||
draft.infoBar.setVisibility(View.GONE);
|
||||
draft.setOverlayVisible(true, false);
|
||||
|
||||
if(uploadingAttachment==null){
|
||||
uploadMediaAttachment(draft);
|
||||
}else{
|
||||
queuedAttachments.add(draft);
|
||||
if(!areThereAnyUploadingAttachments()){
|
||||
uploadNextQueuedAttachment();
|
||||
}
|
||||
updatePublishButtonState();
|
||||
updateSensitive();
|
||||
if(getMediaAttachmentsCount()==MAX_ATTACHMENTS)
|
||||
mediaBtn.setEnabled(false);
|
||||
return true;
|
||||
@@ -875,25 +930,35 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private View createMediaAttachmentView(DraftMediaAttachment draft){
|
||||
View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false);
|
||||
ImageView img=thumb.findViewById(R.id.thumb);
|
||||
ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250)));
|
||||
if(draft.serverAttachment!=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)));
|
||||
}else if(draft.mimeType.startsWith("video/")){
|
||||
loadVideoThumbIntoView(img, draft.uri);
|
||||
}
|
||||
}
|
||||
TextView fileName=thumb.findViewById(R.id.file_name);
|
||||
fileName.setText(UiUtils.getFileName(draft.uri));
|
||||
fileName.setText(UiUtils.getFileName(draft.serverAttachment!=null ? Uri.parse(draft.serverAttachment.url) : draft.uri));
|
||||
|
||||
draft.view=thumb;
|
||||
draft.imageView=img;
|
||||
draft.progressBar=thumb.findViewById(R.id.progress);
|
||||
draft.infoBar=thumb.findViewById(R.id.info_bar);
|
||||
draft.overlay=thumb.findViewById(R.id.overlay);
|
||||
draft.descriptionView=thumb.findViewById(R.id.description);
|
||||
draft.uploadStateTitle=thumb.findViewById(R.id.state_title);
|
||||
draft.uploadStateText=thumb.findViewById(R.id.state_text);
|
||||
ImageButton btn=thumb.findViewById(R.id.remove_btn);
|
||||
btn.setTag(draft);
|
||||
btn.setOnClickListener(this::onRemoveMediaAttachmentClick);
|
||||
btn=thumb.findViewById(R.id.remove_btn2);
|
||||
btn.setTag(draft);
|
||||
btn.setOnClickListener(this::onRemoveMediaAttachmentClick);
|
||||
Button retry=thumb.findViewById(R.id.retry_upload);
|
||||
ImageButton retry=thumb.findViewById(R.id.retry_or_cancel_upload);
|
||||
retry.setTag(draft);
|
||||
retry.setOnClickListener(this::onRetryMediaUploadClick);
|
||||
retry.setVisibility(View.GONE);
|
||||
retry.setOnClickListener(this::onRetryOrCancelMediaUploadClick);
|
||||
draft.retryButton=retry;
|
||||
draft.infoBar.setTag(draft);
|
||||
draft.infoBar.setOnClickListener(this::onEditMediaDescriptionClick);
|
||||
@@ -901,12 +966,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
if(!TextUtils.isEmpty(draft.description))
|
||||
draft.descriptionView.setText(draft.description);
|
||||
|
||||
if(uploadingAttachment!=draft && !queuedAttachments.contains(draft)){
|
||||
draft.progressBar.setVisibility(View.GONE);
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){
|
||||
draft.overlay.setBackgroundColor(0xA6000000);
|
||||
}
|
||||
if(failedAttachments.contains(draft)){
|
||||
draft.infoBar.setVisibility(View.GONE);
|
||||
draft.overlay.setVisibility(View.VISIBLE);
|
||||
|
||||
if(draft.state==AttachmentUploadState.UPLOADING || draft.state==AttachmentUploadState.PROCESSING || draft.state==AttachmentUploadState.QUEUED){
|
||||
draft.progressBar.setVisibility(View.GONE);
|
||||
}else if(draft.state==AttachmentUploadState.ERROR){
|
||||
draft.setOverlayVisible(true, false);
|
||||
}
|
||||
|
||||
return thumb;
|
||||
@@ -918,67 +985,92 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
draft.uri=uri;
|
||||
draft.description=description;
|
||||
attachmentsView.addView(createMediaAttachmentView(draft));
|
||||
allAttachments.add(draft);
|
||||
attachments.add(draft);
|
||||
attachmentsView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private void uploadMediaAttachment(DraftMediaAttachment attachment){
|
||||
if(uploadingAttachment!=null)
|
||||
throw new IllegalStateException("there is already an attachment being uploaded");
|
||||
uploadingAttachment=attachment;
|
||||
if(areThereAnyUploadingAttachments()){
|
||||
throw new IllegalStateException("there is already an attachment being uploaded");
|
||||
}
|
||||
attachment.state=AttachmentUploadState.UPLOADING;
|
||||
attachment.progressBar.setVisibility(View.VISIBLE);
|
||||
ObjectAnimator rotationAnimator=ObjectAnimator.ofFloat(attachment.progressBar, View.ROTATION, 0f, 360f);
|
||||
rotationAnimator.setInterpolator(new LinearInterpolator());
|
||||
rotationAnimator.setDuration(1500);
|
||||
rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE);
|
||||
rotationAnimator.start();
|
||||
attachment.progressBarAnimator=rotationAnimator;
|
||||
int maxSize=0;
|
||||
String contentType=getActivity().getContentResolver().getType(attachment.uri);
|
||||
if(contentType!=null && contentType.startsWith("image/")){
|
||||
maxSize=2_073_600; // TODO get this from instance configuration when it gets added there
|
||||
}
|
||||
attachment.uploadStateTitle.setText("");
|
||||
attachment.uploadStateText.setText("");
|
||||
attachment.progressBar.setProgress(0);
|
||||
attachment.speedTracker.reset();
|
||||
attachment.speedTracker.addSample(0);
|
||||
attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri, maxSize, attachment.description)
|
||||
.setProgressListener(new ProgressListener(){
|
||||
@Override
|
||||
public void onProgress(long transferred, long total){
|
||||
if(updateUploadEtaRunnable==null){
|
||||
UiUtils.runOnUiThread(updateUploadEtaRunnable=ComposeFragment.this::updateUploadETAs, 100);
|
||||
}
|
||||
int progress=Math.round(transferred/(float)total*attachment.progressBar.getMax());
|
||||
if(Build.VERSION.SDK_INT>=24)
|
||||
attachment.progressBar.setProgress(progress, true);
|
||||
else
|
||||
attachment.progressBar.setProgress(progress);
|
||||
|
||||
attachment.speedTracker.setTotalBytes(total);
|
||||
attachment.uploadStateTitle.setText(getString(R.string.file_upload_progress, UiUtils.formatFileSize(getActivity(), transferred, true), UiUtils.formatFileSize(getActivity(), total, true)));
|
||||
attachment.speedTracker.addSample(transferred);
|
||||
}
|
||||
})
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Attachment result){
|
||||
attachment.serverAttachment=result;
|
||||
attachment.uploadRequest=null;
|
||||
uploadingAttachment=null;
|
||||
attachments.add(attachment);
|
||||
attachment.progressBar.setVisibility(View.GONE);
|
||||
if(!queuedAttachments.isEmpty())
|
||||
uploadMediaAttachment(queuedAttachments.remove(0));
|
||||
updatePublishButtonState();
|
||||
|
||||
rotationAnimator.cancel();
|
||||
V.setVisibilityAnimated(attachment.overlay, View.GONE);
|
||||
V.setVisibilityAnimated(attachment.infoBar, View.VISIBLE);
|
||||
if(TextUtils.isEmpty(result.url)){
|
||||
attachment.state=AttachmentUploadState.PROCESSING;
|
||||
attachment.processingPollingRunnable=()->pollForMediaAttachmentProcessing(attachment);
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
attachment.uploadStateTitle.setText(R.string.upload_processing);
|
||||
attachment.uploadStateText.setText("");
|
||||
UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
|
||||
if(!areThereAnyUploadingAttachments())
|
||||
uploadNextQueuedAttachment();
|
||||
}else{
|
||||
finishMediaAttachmentUpload(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
attachment.uploadRequest=null;
|
||||
uploadingAttachment=null;
|
||||
failedAttachments.add(attachment);
|
||||
// error.showToast(getActivity());
|
||||
Toast.makeText(getActivity(), R.string.image_upload_failed, Toast.LENGTH_SHORT).show();
|
||||
attachment.progressBarAnimator=null;
|
||||
attachment.state=AttachmentUploadState.ERROR;
|
||||
attachment.uploadStateTitle.setText(R.string.upload_failed);
|
||||
if(error instanceof MastodonErrorResponse er){
|
||||
if(er.underlyingException instanceof SocketException || er.underlyingException instanceof UnknownHostException || er.underlyingException instanceof InterruptedIOException)
|
||||
attachment.uploadStateText.setText(R.string.upload_error_connection_lost);
|
||||
else
|
||||
attachment.uploadStateText.setText(er.error);
|
||||
}else{
|
||||
attachment.uploadStateText.setText("");
|
||||
}
|
||||
attachment.retryButton.setImageResource(R.drawable.ic_fluent_arrow_clockwise_24_filled);
|
||||
attachment.retryButton.setContentDescription(getString(R.string.retry_upload));
|
||||
|
||||
rotationAnimator.cancel();
|
||||
V.setVisibilityAnimated(attachment.retryButton, View.VISIBLE);
|
||||
V.setVisibilityAnimated(attachment.progressBar, View.GONE);
|
||||
|
||||
if(!queuedAttachments.isEmpty())
|
||||
uploadMediaAttachment(queuedAttachments.remove(0));
|
||||
if(!areThereAnyUploadingAttachments())
|
||||
uploadNextQueuedAttachment();
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
@@ -986,37 +1078,110 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
|
||||
private void onRemoveMediaAttachmentClick(View v){
|
||||
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
|
||||
if(att==uploadingAttachment){
|
||||
att.uploadRequest.cancel();
|
||||
uploadingAttachment=null;
|
||||
if(!queuedAttachments.isEmpty())
|
||||
uploadMediaAttachment(queuedAttachments.remove(0));
|
||||
}else{
|
||||
attachments.remove(att);
|
||||
queuedAttachments.remove(att);
|
||||
failedAttachments.remove(att);
|
||||
}
|
||||
allAttachments.remove(att);
|
||||
if(att.isUploadingOrProcessing())
|
||||
att.cancelUpload();
|
||||
attachments.remove(att);
|
||||
uploadNextQueuedAttachment();
|
||||
attachmentsView.removeView(att.view);
|
||||
if(getMediaAttachmentsCount()==0)
|
||||
attachmentsView.setVisibility(View.GONE);
|
||||
updatePublishButtonState();
|
||||
pollBtn.setEnabled(attachments.isEmpty() && queuedAttachments.isEmpty() && failedAttachments.isEmpty() && uploadingAttachment==null);
|
||||
pollBtn.setEnabled(attachments.isEmpty());
|
||||
mediaBtn.setEnabled(true);
|
||||
updateSensitive();
|
||||
}
|
||||
|
||||
private void onRetryMediaUploadClick(View v){
|
||||
private void onRetryOrCancelMediaUploadClick(View v){
|
||||
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
|
||||
if(failedAttachments.remove(att)){
|
||||
V.setVisibilityAnimated(att.retryButton, View.GONE);
|
||||
if(att.state==AttachmentUploadState.ERROR){
|
||||
att.retryButton.setImageResource(R.drawable.ic_fluent_dismiss_24_filled);
|
||||
att.retryButton.setContentDescription(getString(R.string.cancel));
|
||||
V.setVisibilityAnimated(att.progressBar, View.VISIBLE);
|
||||
if(uploadingAttachment==null)
|
||||
uploadMediaAttachment(att);
|
||||
else
|
||||
queuedAttachments.add(att);
|
||||
att.state=AttachmentUploadState.QUEUED;
|
||||
if(!areThereAnyUploadingAttachments()){
|
||||
uploadNextQueuedAttachment();
|
||||
}
|
||||
}else{
|
||||
onRemoveMediaAttachmentClick(v);
|
||||
}
|
||||
}
|
||||
|
||||
private void pollForMediaAttachmentProcessing(DraftMediaAttachment attachment){
|
||||
attachment.processingPollingRequest=(GetAttachmentByID) new GetAttachmentByID(attachment.serverAttachment.id)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Attachment result){
|
||||
attachment.processingPollingRequest=null;
|
||||
if(!TextUtils.isEmpty(result.url)){
|
||||
attachment.processingPollingRunnable=null;
|
||||
attachment.serverAttachment=result;
|
||||
finishMediaAttachmentUpload(attachment);
|
||||
}else if(getActivity()!=null){
|
||||
UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
attachment.processingPollingRequest=null;
|
||||
if(getActivity()!=null)
|
||||
UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
private void finishMediaAttachmentUpload(DraftMediaAttachment attachment){
|
||||
if(attachment.state!=AttachmentUploadState.PROCESSING && attachment.state!=AttachmentUploadState.UPLOADING)
|
||||
throw new IllegalStateException("Unexpected state "+attachment.state);
|
||||
attachment.uploadRequest=null;
|
||||
attachment.state=AttachmentUploadState.DONE;
|
||||
attachment.progressBar.setVisibility(View.GONE);
|
||||
if(!areThereAnyUploadingAttachments())
|
||||
uploadNextQueuedAttachment();
|
||||
updatePublishButtonState();
|
||||
|
||||
if(attachment.progressBarAnimator!=null){
|
||||
attachment.progressBarAnimator.cancel();
|
||||
attachment.progressBarAnimator=null;
|
||||
}
|
||||
attachment.setOverlayVisible(false, true);
|
||||
}
|
||||
|
||||
private void uploadNextQueuedAttachment(){
|
||||
for(DraftMediaAttachment att:attachments){
|
||||
if(att.state==AttachmentUploadState.QUEUED){
|
||||
uploadMediaAttachment(att);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean areThereAnyUploadingAttachments(){
|
||||
for(DraftMediaAttachment att:attachments){
|
||||
if(att.state==AttachmentUploadState.UPLOADING)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void updateUploadETAs(){
|
||||
if(!areThereAnyUploadingAttachments()){
|
||||
UiUtils.removeCallbacks(updateUploadEtaRunnable);
|
||||
updateUploadEtaRunnable=null;
|
||||
return;
|
||||
}
|
||||
for(DraftMediaAttachment att:attachments){
|
||||
if(att.state==AttachmentUploadState.UPLOADING){
|
||||
long eta=att.speedTracker.updateAndGetETA();
|
||||
// Log.i(TAG, "onProgress: transfer speed "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getLastSpeed()), false)+" average "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getAverageSpeed()), false)+" eta "+eta);
|
||||
String time=String.format("%d:%02d", eta/60, eta%60);
|
||||
att.uploadStateText.setText(getString(R.string.file_upload_time_remaining, time));
|
||||
}
|
||||
}
|
||||
UiUtils.runOnUiThread(updateUploadEtaRunnable, 100);
|
||||
}
|
||||
|
||||
private void onEditMediaDescriptionClick(View v){
|
||||
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
|
||||
if(att.serverAttachment==null)
|
||||
@@ -1125,11 +1290,24 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
spoilerBtn.setSelected(false);
|
||||
mainEditText.requestFocus();
|
||||
updateCharCounter();
|
||||
sensitiveIcon.setVisibility(getMediaAttachmentsCount() > 0 ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
updateSensitive();
|
||||
}
|
||||
|
||||
private void toggleSensitive() {
|
||||
sensitive=!sensitive;
|
||||
sensitiveIcon.setSelected(sensitive);
|
||||
}
|
||||
|
||||
private void updateSensitive() {
|
||||
sensitiveItem.setVisibility(View.GONE);
|
||||
if (!attachments.isEmpty() && !hasSpoiler) sensitiveItem.setVisibility(View.VISIBLE);
|
||||
if (attachments.isEmpty()) sensitive = false;
|
||||
}
|
||||
|
||||
private int getMediaAttachmentsCount(){
|
||||
return allAttachments.size();
|
||||
return attachments.size();
|
||||
}
|
||||
|
||||
private void onVisibilityClick(View v){
|
||||
@@ -1165,12 +1343,53 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
menu.show();
|
||||
}
|
||||
|
||||
private void loadDefaultStatusVisibility(Bundle savedInstanceState) {
|
||||
if(getArguments().containsKey("replyTo")){
|
||||
replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo"));
|
||||
statusVisibility = replyTo.visibility;
|
||||
}
|
||||
|
||||
// A saved privacy setting from a previous compose session wins over the reply visibility
|
||||
if(savedInstanceState !=null){
|
||||
statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility");
|
||||
}
|
||||
|
||||
new GetPreferences()
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Preferences result){
|
||||
// Only override the reply visibility if our preference is more private
|
||||
if (result.postingDefaultVisibility.isLessVisibleThan(statusVisibility)) {
|
||||
statusVisibility = switch (result.postingDefaultVisibility) {
|
||||
case PUBLIC -> StatusPrivacy.PUBLIC;
|
||||
case UNLISTED -> StatusPrivacy.UNLISTED;
|
||||
case PRIVATE -> StatusPrivacy.PRIVATE;
|
||||
case DIRECT -> StatusPrivacy.DIRECT;
|
||||
};
|
||||
}
|
||||
|
||||
// A saved privacy setting from a previous compose session wins over all
|
||||
if(savedInstanceState !=null){
|
||||
statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility");
|
||||
}
|
||||
|
||||
updateVisibilityIcon ();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
Log.w(TAG, "Unable to get user preferences to set default post privacy");
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
private void updateVisibilityIcon(){
|
||||
if(statusVisibility==null){ // TODO find out why this happens
|
||||
statusVisibility=StatusPrivacy.PUBLIC;
|
||||
}
|
||||
visibilityBtn.setImageResource(switch(statusVisibility){
|
||||
case PUBLIC -> R.drawable.ic_fluent_earth_24_filled;
|
||||
case PUBLIC -> R.drawable.ic_fluent_earth_24_regular;
|
||||
case UNLISTED -> R.drawable.ic_fluent_people_community_24_regular;
|
||||
case PRIVATE -> R.drawable.ic_fluent_people_checkmark_24_regular;
|
||||
case DIRECT -> R.drawable.ic_at_symbol;
|
||||
@@ -1179,6 +1398,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged(int start, int end){
|
||||
if(ignoreSelectionChanges)
|
||||
return;
|
||||
if(start==end && mainEditText.length()>0){
|
||||
ComposeAutocompleteSpan[] spans=mainEditText.getText().getSpans(start, end, ComposeAutocompleteSpan.class);
|
||||
if(spans.length>0){
|
||||
@@ -1249,6 +1470,30 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
finishAutocomplete();
|
||||
}
|
||||
|
||||
private void loadVideoThumbIntoView(ImageView target, Uri uri){
|
||||
MastodonAPIController.runInBackground(()->{
|
||||
Context context=getActivity();
|
||||
if(context==null)
|
||||
return;
|
||||
try{
|
||||
MediaMetadataRetriever mmr=new MediaMetadataRetriever();
|
||||
mmr.setDataSource(context, uri);
|
||||
Bitmap frame=mmr.getFrameAtTime(3_000_000);
|
||||
mmr.release();
|
||||
int size=Math.max(frame.getWidth(), frame.getHeight());
|
||||
int maxSize=V.dp(250);
|
||||
if(size>maxSize){
|
||||
float factor=maxSize/(float)size;
|
||||
frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true);
|
||||
}
|
||||
Bitmap finalFrame=frame;
|
||||
target.post(()->target.setImageBitmap(finalFrame));
|
||||
}catch(Exception x){
|
||||
Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence getTitle(){
|
||||
return getString(R.string.new_post);
|
||||
@@ -1269,14 +1514,75 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
public Attachment serverAttachment;
|
||||
public Uri uri;
|
||||
public transient UploadAttachment uploadRequest;
|
||||
public transient GetAttachmentByID processingPollingRequest;
|
||||
public String description;
|
||||
public String mimeType;
|
||||
public AttachmentUploadState state=AttachmentUploadState.QUEUED;
|
||||
|
||||
public transient View view;
|
||||
public transient ProgressBar progressBar;
|
||||
public transient TextView descriptionView;
|
||||
public transient View overlay;
|
||||
public transient View infoBar;
|
||||
public transient Button retryButton;
|
||||
public transient ImageButton retryButton;
|
||||
public transient ObjectAnimator progressBarAnimator;
|
||||
public transient Runnable processingPollingRunnable;
|
||||
public transient ImageView imageView;
|
||||
public transient TextView uploadStateTitle, uploadStateText;
|
||||
public transient TransferSpeedTracker speedTracker=new TransferSpeedTracker();
|
||||
|
||||
public void cancelUpload(){
|
||||
switch(state){
|
||||
case UPLOADING -> {
|
||||
if(uploadRequest!=null){
|
||||
uploadRequest.cancel();
|
||||
uploadRequest=null;
|
||||
}
|
||||
}
|
||||
case PROCESSING -> {
|
||||
if(processingPollingRunnable!=null){
|
||||
UiUtils.removeCallbacks(processingPollingRunnable);
|
||||
processingPollingRunnable=null;
|
||||
}
|
||||
if(processingPollingRequest!=null){
|
||||
processingPollingRequest.cancel();
|
||||
processingPollingRequest=null;
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalStateException("Unexpected state "+state);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isUploadingOrProcessing(){
|
||||
return state==AttachmentUploadState.UPLOADING || state==AttachmentUploadState.PROCESSING;
|
||||
}
|
||||
|
||||
public void setOverlayVisible(boolean visible, boolean animated){
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){
|
||||
if(visible){
|
||||
imageView.setRenderEffect(RenderEffect.createBlurEffect(V.dp(16), V.dp(16), Shader.TileMode.REPEAT));
|
||||
}else{
|
||||
imageView.setRenderEffect(null);
|
||||
}
|
||||
}
|
||||
int infoBarVis=visible ? View.GONE : View.VISIBLE;
|
||||
int overlayVis=visible ? View.VISIBLE : View.GONE;
|
||||
if(animated){
|
||||
V.setVisibilityAnimated(infoBar, infoBarVis);
|
||||
V.setVisibilityAnimated(overlay, overlayVis);
|
||||
}else{
|
||||
infoBar.setVisibility(infoBarVis);
|
||||
overlay.setVisibility(overlayVis);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AttachmentUploadState{
|
||||
QUEUED,
|
||||
UPLOADING,
|
||||
PROCESSING,
|
||||
ERROR,
|
||||
DONE
|
||||
}
|
||||
|
||||
private static class DraftPollOption{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.os.Bundle;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetFavourites;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
|
||||
public class FavoritesListFragment extends StatusListFragment{
|
||||
|
||||
private String accountID;
|
||||
private String lastMaxId=null;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
accountID=getArguments().getString("account");
|
||||
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
|
||||
setTitle(R.string.favorited_posts);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onShown(){
|
||||
super.onShown();
|
||||
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
|
||||
loadData();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count) {
|
||||
GetFavourites b=new GetFavourites(offset>0 ? lastMaxId : null, null, count);
|
||||
currentRequest=b.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
onDataLoaded(result, b.getMaxId()!=null);
|
||||
lastMaxId=b.getMaxId();
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Animatable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetFollowRequests;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.views.ProgressBarButton;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.fragments.BaseRecyclerFragment;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
|
||||
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
|
||||
public class FollowRequestsListFragment extends BaseRecyclerFragment<FollowRequestsListFragment.AccountWrapper> implements ScrollableToTop{
|
||||
private String accountID;
|
||||
private Map<String, Relationship> relationships=Collections.emptyMap();
|
||||
private GetAccountRelationships relationshipsRequest;
|
||||
private String lastMaxId=null;
|
||||
|
||||
public FollowRequestsListFragment(){
|
||||
super(20);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
accountID=getArguments().getString("account");
|
||||
loadData();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Activity activity) {
|
||||
super.onAttach(activity);
|
||||
setTitle(R.string.follow_requests);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
if(relationshipsRequest!=null){
|
||||
relationshipsRequest.cancel();
|
||||
relationshipsRequest=null;
|
||||
}
|
||||
currentRequest=new GetFollowRequests(offset>0 ? lastMaxId : null, null, count)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Account> result){
|
||||
onDataLoaded(result.stream().map(AccountWrapper::new).collect(Collectors.toList()), false);
|
||||
loadRelationships();
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RecyclerView.Adapter getAdapter(){
|
||||
return new AccountsAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
list.addItemDecoration(new RecyclerView.ItemDecoration(){
|
||||
@Override
|
||||
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
|
||||
outRect.bottom=outRect.left=outRect.right=V.dp(16);
|
||||
if(parent.getChildAdapterPosition(view)==0)
|
||||
outRect.top=V.dp(16);
|
||||
}
|
||||
});
|
||||
((UsableRecyclerView)list).setDrawSelectorOnTop(true);
|
||||
}
|
||||
|
||||
private void loadRelationships(){
|
||||
relationships=Collections.emptyMap();
|
||||
relationshipsRequest=new GetAccountRelationships(data.stream().map(fs->fs.account.id).collect(Collectors.toList()));
|
||||
relationshipsRequest.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(List<Relationship> result){
|
||||
relationshipsRequest=null;
|
||||
relationships=result.stream().collect(Collectors.toMap(rel->rel.id, Function.identity()));
|
||||
if(list==null)
|
||||
return;
|
||||
for(int i=0;i<list.getChildCount();i++){
|
||||
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
|
||||
if(holder instanceof AccountViewHolder avh)
|
||||
avh.rebind();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
relationshipsRequest=null;
|
||||
}
|
||||
}).exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView(){
|
||||
super.onDestroyView();
|
||||
if(relationshipsRequest!=null){
|
||||
relationshipsRequest.cancel();
|
||||
relationshipsRequest=null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void scrollToTop(){
|
||||
smoothScrollRecyclerViewToTop(list);
|
||||
}
|
||||
|
||||
private class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
|
||||
|
||||
public AccountsAdapter(){
|
||||
super(imgLoader);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(AccountViewHolder holder, int position){
|
||||
holder.bind(data.get(position));
|
||||
super.onBindViewHolder(holder, position);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
return new AccountViewHolder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount(){
|
||||
return data.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getImageCountForItem(int position){
|
||||
return 2+data.get(position).emojiHelper.getImageCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImageLoaderRequest getImageRequest(int position, int image){
|
||||
AccountWrapper item=data.get(position);
|
||||
if(image==0)
|
||||
return item.avaRequest;
|
||||
else if(image==1)
|
||||
return item.coverRequest;
|
||||
else
|
||||
return item.emojiHelper.getImageRequest(image-2);
|
||||
}
|
||||
}
|
||||
|
||||
// literally the same as AccountCardStatusDisplayItem and DiscoverAccountsFragment. code should be generalized
|
||||
private class AccountViewHolder extends BindableViewHolder<AccountWrapper> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
|
||||
private final ImageView cover, avatar;
|
||||
private final TextView name, username, bio, followersCount, followingCount, postsCount, followersLabel, followingLabel, postsLabel;
|
||||
private final ProgressBarButton actionButton, acceptButton, rejectButton;
|
||||
private final ProgressBar actionProgress, acceptProgress, rejectProgress;
|
||||
private final View actionWrap, acceptWrap, rejectWrap;
|
||||
|
||||
private Relationship relationship;
|
||||
|
||||
public AccountViewHolder(){
|
||||
super(getActivity(), R.layout.item_discover_account, list);
|
||||
cover=findViewById(R.id.cover);
|
||||
avatar=findViewById(R.id.avatar);
|
||||
name=findViewById(R.id.name);
|
||||
username=findViewById(R.id.username);
|
||||
bio=findViewById(R.id.bio);
|
||||
followersCount=findViewById(R.id.followers_count);
|
||||
followersLabel=findViewById(R.id.followers_label);
|
||||
followingCount=findViewById(R.id.following_count);
|
||||
followingLabel=findViewById(R.id.following_label);
|
||||
postsCount=findViewById(R.id.posts_count);
|
||||
postsLabel=findViewById(R.id.posts_label);
|
||||
actionButton=findViewById(R.id.action_btn);
|
||||
actionProgress=findViewById(R.id.action_progress);
|
||||
actionWrap=findViewById(R.id.action_btn_wrap);
|
||||
acceptButton=findViewById(R.id.accept_btn);
|
||||
acceptProgress=findViewById(R.id.accept_progress);
|
||||
acceptWrap=findViewById(R.id.accept_btn_wrap);
|
||||
rejectButton=findViewById(R.id.reject_btn);
|
||||
rejectProgress=findViewById(R.id.reject_progress);
|
||||
rejectWrap=findViewById(R.id.reject_btn_wrap);
|
||||
|
||||
itemView.setOutlineProvider(OutlineProviders.roundedRect(6));
|
||||
itemView.setClipToOutline(true);
|
||||
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
|
||||
avatar.setClipToOutline(true);
|
||||
cover.setOutlineProvider(OutlineProviders.roundedRect(3));
|
||||
cover.setClipToOutline(true);
|
||||
actionButton.setOnClickListener(this::onActionButtonClick);
|
||||
acceptButton.setOnClickListener(this::onFollowRequestButtonClick);
|
||||
rejectButton.setOnClickListener(this::onFollowRequestButtonClick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(AccountWrapper item){
|
||||
name.setText(item.parsedName);
|
||||
username.setText('@'+item.account.acct);
|
||||
bio.setText(item.parsedBio);
|
||||
followersCount.setText(UiUtils.abbreviateNumber(item.account.followersCount));
|
||||
followingCount.setText(UiUtils.abbreviateNumber(item.account.followingCount));
|
||||
postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount));
|
||||
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount)));
|
||||
followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount)));
|
||||
postsLabel.setText(getResources().getQuantityString(R.plurals.posts, (int)Math.min(999, item.account.statusesCount)));
|
||||
relationship=relationships.get(item.account.id);
|
||||
if(relationship == null || !relationship.followedBy){
|
||||
actionWrap.setVisibility(View.GONE);
|
||||
acceptWrap.setVisibility(View.VISIBLE);
|
||||
rejectWrap.setVisibility(View.VISIBLE);
|
||||
|
||||
// i hate that i wasn't able to do this in xml
|
||||
acceptButton.setCompoundDrawableTintList(acceptButton.getTextColors());
|
||||
acceptProgress.setIndeterminateTintList(acceptButton.getTextColors());
|
||||
rejectButton.setCompoundDrawableTintList(rejectButton.getTextColors());
|
||||
rejectProgress.setIndeterminateTintList(rejectButton.getTextColors());
|
||||
}else if(relationship==null){
|
||||
actionWrap.setVisibility(View.GONE);
|
||||
acceptWrap.setVisibility(View.GONE);
|
||||
rejectWrap.setVisibility(View.GONE);
|
||||
}else{
|
||||
actionWrap.setVisibility(View.VISIBLE);
|
||||
acceptWrap.setVisibility(View.GONE);
|
||||
rejectWrap.setVisibility(View.GONE);
|
||||
UiUtils.setRelationshipToActionButton(relationship, actionButton);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setImage(int index, Drawable image){
|
||||
if(index==0){
|
||||
avatar.setImageDrawable(image);
|
||||
}else if(index==1){
|
||||
cover.setImageDrawable(image);
|
||||
}else{
|
||||
item.emojiHelper.setImageDrawable(index-2, image);
|
||||
name.invalidate();
|
||||
bio.invalidate();
|
||||
}
|
||||
if(image instanceof Animatable a && !a.isRunning())
|
||||
a.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearImage(int index){
|
||||
setImage(index, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("profileAccount", Parcels.wrap(item.account));
|
||||
Nav.go(getActivity(), ProfileFragment.class, args);
|
||||
}
|
||||
|
||||
private void onFollowRequestButtonClick(View v) {
|
||||
itemView.setHasTransientState(true);
|
||||
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, accountID, null, v == acceptButton, relationship, rel -> {
|
||||
itemView.setHasTransientState(false);
|
||||
relationships.put(item.account.id, rel);
|
||||
RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter = getBindingAdapter();
|
||||
if (!rel.requested && !rel.followedBy && adapter != null) {
|
||||
data.remove(item);
|
||||
adapter.notifyItemRemoved(getBindingAdapterPosition());
|
||||
} else {
|
||||
rebind();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void onActionButtonClick(View v){
|
||||
itemView.setHasTransientState(true);
|
||||
UiUtils.performAccountAction(getActivity(), item.account, accountID, relationship, actionButton, this::setActionProgressVisible, rel->{
|
||||
itemView.setHasTransientState(false);
|
||||
relationships.put(item.account.id, rel);
|
||||
rebind();
|
||||
});
|
||||
}
|
||||
|
||||
private void setActionProgressVisible(boolean visible){
|
||||
actionButton.setTextVisible(!visible);
|
||||
actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
actionButton.setClickable(!visible);
|
||||
}
|
||||
}
|
||||
|
||||
protected class AccountWrapper{
|
||||
public Account account;
|
||||
public ImageLoaderRequest avaRequest, coverRequest;
|
||||
public CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
|
||||
public CharSequence parsedName, parsedBio;
|
||||
|
||||
public AccountWrapper(Account account){
|
||||
this.account=account;
|
||||
if(!TextUtils.isEmpty(account.avatar))
|
||||
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));
|
||||
if(!TextUtils.isEmpty(account.header))
|
||||
coverRequest=new UrlImageLoaderRequest(account.header, 1000, 1000);
|
||||
parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
|
||||
if(account.emojis.isEmpty()){
|
||||
parsedName=account.displayName;
|
||||
}else{
|
||||
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
|
||||
emojiHelper.setText(new SpannableStringBuilder(parsedName).append(parsedBio));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,34 @@ package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.tags.GetHashtag;
|
||||
import org.joinmastodon.android.api.requests.tags.SetHashtagFollowed;
|
||||
import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline;
|
||||
import org.joinmastodon.android.model.Hashtag;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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.V;
|
||||
|
||||
public class HashtagTimelineFragment extends StatusListFragment{
|
||||
private String hashtag;
|
||||
private boolean following;
|
||||
private ImageButton fab;
|
||||
private MenuItem followButton;
|
||||
|
||||
public HashtagTimelineFragment(){
|
||||
setListLayoutId(R.layout.recycler_fragment_with_fab);
|
||||
@@ -27,10 +38,61 @@ public class HashtagTimelineFragment extends StatusListFragment{
|
||||
@Override
|
||||
public void onAttach(Activity activity){
|
||||
super.onAttach(activity);
|
||||
hashtag=getArguments().getString("hashtag");
|
||||
updateTitle(getArguments().getString("hashtag"));
|
||||
following=getArguments().getBoolean("following", false);
|
||||
|
||||
setHasOptionsMenu(true);
|
||||
}
|
||||
|
||||
private void updateTitle(String hashtagName) {
|
||||
hashtag = hashtagName;
|
||||
setTitle('#'+hashtag);
|
||||
}
|
||||
|
||||
private void updateFollowingState(boolean newFollowing) {
|
||||
this.following = newFollowing;
|
||||
followButton.setTitle(getString(newFollowing ? R.string.unfollow_user : R.string.follow_user, "#" + hashtag));
|
||||
followButton.setIcon(newFollowing ? R.drawable.ic_fluent_person_delete_24_filled : R.drawable.ic_fluent_person_add_24_regular);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
||||
inflater.inflate(R.menu.hashtag_timeline, menu);
|
||||
followButton = menu.findItem(R.id.follow_hashtag);
|
||||
updateFollowingState(following);
|
||||
|
||||
followButton.setOnMenuItemClickListener(i -> {
|
||||
updateFollowingState(!following);
|
||||
new SetHashtagFollowed(hashtag, following).setCallback(new Callback<>() {
|
||||
@Override
|
||||
public void onSuccess(Hashtag i) {
|
||||
if (i.following == following) Toast.makeText(getActivity(), getString(i.following ? R.string.followed_user : R.string.unfollowed_user, "#" + i.name), Toast.LENGTH_SHORT).show();
|
||||
updateFollowingState(i.following);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error) {
|
||||
error.showToast(getActivity());
|
||||
updateFollowingState(!following);
|
||||
}
|
||||
}).exec(accountID);
|
||||
return true;
|
||||
});
|
||||
|
||||
new GetHashtag(hashtag).setCallback(new Callback<>() {
|
||||
@Override
|
||||
public void onSuccess(Hashtag hashtag) {
|
||||
updateTitle(hashtag.name);
|
||||
updateFollowingState(hashtag.following);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error) {
|
||||
error.showToast(getActivity());
|
||||
}
|
||||
}).exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
currentRequest=new GetHashtagTimeline(hashtag, offset==0 ? null : getMaxID(), null, count)
|
||||
|
||||
@@ -2,13 +2,9 @@ package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.app.Fragment;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Outline;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -18,20 +14,14 @@ import android.view.WindowInsets;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.PushNotificationReceiver;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.discover.DiscoverFragment;
|
||||
import org.joinmastodon.android.fragments.discover.SearchFragment;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.ui.AccountSwitcherSheet;
|
||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.views.TabBar;
|
||||
import org.parceler.Parcels;
|
||||
@@ -41,15 +31,12 @@ import java.util.ArrayList;
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.Nullable;
|
||||
import me.grishka.appkit.FragmentStackActivity;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.fragments.AppKitFragment;
|
||||
import me.grishka.appkit.fragments.LoaderFragment;
|
||||
import me.grishka.appkit.fragments.OnBackPressedListener;
|
||||
import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.BottomSheet;
|
||||
import me.grishka.appkit.views.FragmentRootLinearLayout;
|
||||
|
||||
public class HomeFragment extends AppKitFragment implements OnBackPressedListener{
|
||||
@@ -141,7 +128,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
|
||||
}
|
||||
});
|
||||
}
|
||||
}else{
|
||||
}
|
||||
|
||||
return content;
|
||||
|
||||
@@ -23,9 +23,12 @@ import android.widget.Toolbar;
|
||||
|
||||
import com.squareup.otto.Subscribe;
|
||||
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
|
||||
import org.joinmastodon.android.events.StatusCreatedEvent;
|
||||
import org.joinmastodon.android.model.CacheablePaginatedResponse;
|
||||
import org.joinmastodon.android.model.Filter;
|
||||
@@ -33,6 +36,7 @@ import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.updater.GithubSelfUpdater;
|
||||
import org.joinmastodon.android.utils.StatusFilterPredicate;
|
||||
|
||||
import java.util.Collections;
|
||||
@@ -70,6 +74,13 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
loadData();
|
||||
}
|
||||
|
||||
private List<Status> filterPosts(List<Status> items) {
|
||||
return items.stream().filter(i ->
|
||||
(GlobalUserPreferences.showReplies || i.inReplyToId == null) &&
|
||||
(GlobalUserPreferences.showBoosts || i.reblog == null)
|
||||
).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
AccountSessionManager.getInstance()
|
||||
@@ -79,7 +90,8 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
onDataLoaded(result.items, !result.items.isEmpty());
|
||||
List<Status> filteredItems = filterPosts(result.items);
|
||||
onDataLoaded(filteredItems, !result.items.isEmpty());
|
||||
maxID=result.maxID;
|
||||
if(result.isFromCache())
|
||||
loadNewPosts();
|
||||
@@ -101,6 +113,11 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if(GithubSelfUpdater.needSelfUpdating()){
|
||||
E.register(this);
|
||||
updateUpdateState(GithubSelfUpdater.getInstance().getState());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -134,7 +151,6 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onStatusCreated(StatusCreatedEvent ev){
|
||||
prependItems(Collections.singletonList(ev.status), true);
|
||||
}
|
||||
@@ -146,6 +162,7 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
}
|
||||
|
||||
private void loadNewPosts(){
|
||||
if (!GlobalUserPreferences.loadNewPosts) return;
|
||||
dataLoading=true;
|
||||
// The idea here is that we request the timeline such that if there are fewer than `limit` posts,
|
||||
// we'll get the currently topmost post as last in the response. This way we know there's no gap
|
||||
@@ -157,6 +174,7 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
public void onSuccess(List<Status> result){
|
||||
currentRequest=null;
|
||||
dataLoading=false;
|
||||
result = filterPosts(result);
|
||||
if(result.isEmpty() || getActivity()==null)
|
||||
return;
|
||||
Status last=result.get(result.size()-1);
|
||||
@@ -250,7 +268,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;
|
||||
}
|
||||
}
|
||||
@@ -397,4 +415,22 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
scrollToTop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView(){
|
||||
super.onDestroyView();
|
||||
if(GithubSelfUpdater.needSelfUpdating()){
|
||||
E.unregister(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateUpdateState(GithubSelfUpdater.UpdateState state){
|
||||
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
|
||||
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_24_badged);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
|
||||
updateUpdateState(ev.state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.media.MediaRouter;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
|
||||
public class ListTimelineFragment extends StatusListFragment {
|
||||
private String listID;
|
||||
private String listTitle;
|
||||
private ImageButton fab;
|
||||
|
||||
public ListTimelineFragment() {
|
||||
setListLayoutId(R.layout.recycler_fragment_with_fab);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Activity activity){
|
||||
super.onAttach(activity);
|
||||
listID=getArguments().getString("listID");
|
||||
listTitle=getArguments().getString("listTitle");
|
||||
setTitle(listTitle);
|
||||
setHasOptionsMenu(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
// TODO: implement edit, delete
|
||||
// inflater.inflate(R.menu.list, menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count) {
|
||||
currentRequest=new GetListTimeline(listID, offset==0 ? null : getMaxID(), null, count, null)
|
||||
.setCallback(new SimpleCallback<>(this) {
|
||||
@Override
|
||||
public void onSuccess(List<Status> result) {
|
||||
onDataLoaded(result, !result.isEmpty());
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onShown() {
|
||||
super.onShown();
|
||||
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
|
||||
loadData();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
fab=view.findViewById(R.id.fab);
|
||||
fab.setOnClickListener(this::onFabClick);
|
||||
}
|
||||
|
||||
private void onFabClick(View v){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
Nav.go(getActivity(), ComposeFragment.class, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSetFabBottomInset(int inset) {
|
||||
((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+inset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.api.requests.lists.AddAccountsToList;
|
||||
import org.joinmastodon.android.api.requests.lists.GetLists;
|
||||
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
|
||||
import org.joinmastodon.android.model.ListTimeline;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.fragments.BaseRecyclerFragment;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
|
||||
public class ListTimelinesFragment extends BaseRecyclerFragment<ListTimeline> implements ScrollableToTop {
|
||||
private String accountId;
|
||||
private String profileAccountId;
|
||||
private String profileDisplayUsername;
|
||||
private HashMap<String, Boolean> userInListBefore = new HashMap<>();
|
||||
private HashMap<String, Boolean> userInList = new HashMap<>();
|
||||
private int inProgress = 0;
|
||||
|
||||
public ListTimelinesFragment() {
|
||||
super(10);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Bundle args=getArguments();
|
||||
accountId=args.getString("account");
|
||||
|
||||
if(args.containsKey("profileAccount")){
|
||||
profileAccountId=args.getString("profileAccount");
|
||||
profileDisplayUsername=args.getString("profileDisplayUsername");
|
||||
setTitle(getString(R.string.lists_with_user, profileDisplayUsername));
|
||||
// setHasOptionsMenu(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onShown(){
|
||||
super.onShown();
|
||||
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
|
||||
loadData();
|
||||
}
|
||||
|
||||
// @Override
|
||||
// public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
||||
// Button saveButton=new Button(getActivity());
|
||||
// saveButton.setText(R.string.save);
|
||||
// saveButton.setOnClickListener(this::onSaveClick);
|
||||
// LinearLayout wrap=new LinearLayout(getActivity());
|
||||
// wrap.setOrientation(LinearLayout.HORIZONTAL);
|
||||
// wrap.addView(saveButton, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
// wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8));
|
||||
// wrap.setClipToPadding(false);
|
||||
// MenuItem item=menu.add(R.string.save);
|
||||
// item.setActionView(wrap);
|
||||
// item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
// }
|
||||
|
||||
private void saveListMembership(String listId, boolean isMember) {
|
||||
userInList.put(listId, isMember);
|
||||
List<String> accountIdList = Collections.singletonList(profileAccountId);
|
||||
MastodonAPIRequest<Object> req = isMember ? new AddAccountsToList(listId, accountIdList) : new RemoveAccountsFromList(listId, accountIdList);
|
||||
req.setCallback(new SimpleCallback<>(this) {
|
||||
@Override
|
||||
public void onSuccess(Object o) {}
|
||||
}).exec(accountId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
userInListBefore.clear();
|
||||
userInList.clear();
|
||||
currentRequest=(profileAccountId != null ? new GetLists(profileAccountId) : new GetLists())
|
||||
.setCallback(new SimpleCallback<>(this) {
|
||||
@Override
|
||||
public void onSuccess(List<ListTimeline> lists) {
|
||||
for (ListTimeline l : lists) userInListBefore.put(l.id, true);
|
||||
userInList.putAll(userInListBefore);
|
||||
if (profileAccountId == null || !lists.isEmpty()) onDataLoaded(lists, false);
|
||||
if (profileAccountId == null) return;
|
||||
|
||||
currentRequest=new GetLists().setCallback(new SimpleCallback<>(ListTimelinesFragment.this) {
|
||||
@Override
|
||||
public void onSuccess(List<ListTimeline> allLists) {
|
||||
List<ListTimeline> newLists = new ArrayList<>();
|
||||
for (ListTimeline l : allLists) {
|
||||
if (lists.stream().noneMatch(e -> e.id.equals(l.id))) newLists.add(l);
|
||||
if (!userInListBefore.containsKey(l.id)) {
|
||||
userInListBefore.put(l.id, false);
|
||||
}
|
||||
}
|
||||
userInList.putAll(userInListBefore);
|
||||
onDataLoaded(newLists, false);
|
||||
}
|
||||
}).exec(accountId);
|
||||
}
|
||||
})
|
||||
.exec(accountId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RecyclerView.Adapter getAdapter() {
|
||||
return new ListsAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void scrollToTop() {
|
||||
smoothScrollRecyclerViewToTop(list);
|
||||
}
|
||||
|
||||
private class ListsAdapter extends RecyclerView.Adapter<ListViewHolder>{
|
||||
@NonNull
|
||||
@Override
|
||||
public ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
return new ListViewHolder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ListViewHolder holder, int position) {
|
||||
holder.bind(data.get(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return data.size();
|
||||
}
|
||||
}
|
||||
|
||||
private class ListViewHolder extends BindableViewHolder<ListTimeline> implements UsableRecyclerView.Clickable{
|
||||
private final TextView title;
|
||||
private final CheckBox listToggle;
|
||||
|
||||
public ListViewHolder(){
|
||||
super(getActivity(), R.layout.item_list_timeline, list);
|
||||
title=findViewById(R.id.title);
|
||||
listToggle=findViewById(R.id.list_toggle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(ListTimeline item) {
|
||||
title.setText(item.title);
|
||||
if (profileAccountId != null) {
|
||||
Boolean checked = userInList.get(item.id);
|
||||
listToggle.setChecked(userInList.containsKey(item.id) && checked != null && checked);
|
||||
listToggle.setOnClickListener(this::onClickToggle);
|
||||
} else {
|
||||
listToggle.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void onClickToggle(View view) {
|
||||
saveListMembership(item.id, listToggle.isChecked());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick() {
|
||||
UiUtils.openListTimeline(getActivity(), accountId, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,22 @@ package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Fragment;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetFollowRequests;
|
||||
import org.joinmastodon.android.events.FollowRequestHandledEvent;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.ui.SimpleViewHolder;
|
||||
import org.joinmastodon.android.ui.tabs.TabLayout;
|
||||
import org.joinmastodon.android.ui.tabs.TabLayoutMediator;
|
||||
@@ -20,8 +26,15 @@ import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.viewpager2.widget.ViewPager2;
|
||||
|
||||
import com.squareup.otto.Subscribe;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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.fragments.ToolbarFragment;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop{
|
||||
@@ -31,7 +44,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc
|
||||
private FrameLayout[] tabViews;
|
||||
private TabLayoutMediator tabLayoutMediator;
|
||||
|
||||
private NotificationsListFragment allNotificationsFragment, mentionsFragment;
|
||||
private NotificationsListFragment allNotificationsFragment, mentionsFragment, postsFragment;
|
||||
|
||||
private String accountID;
|
||||
|
||||
@@ -42,14 +55,36 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc
|
||||
setRetainInstance(true);
|
||||
|
||||
accountID=getArguments().getString("account");
|
||||
E.register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
E.unregister(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Activity activity){
|
||||
super.onAttach(activity);
|
||||
setHasOptionsMenu(true);
|
||||
setTitle(R.string.notifications);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
|
||||
inflater.inflate(R.menu.notifications, menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
if (item.getItemId() != R.id.follow_requests) return false;
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
Nav.go(getActivity(), FollowRequestsListFragment.class, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
|
||||
LinearLayout view=(LinearLayout) inflater.inflate(R.layout.fragment_notifications, container, false);
|
||||
@@ -57,12 +92,13 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc
|
||||
tabLayout=view.findViewById(R.id.tabbar);
|
||||
pager=view.findViewById(R.id.pager);
|
||||
|
||||
tabViews=new FrameLayout[2];
|
||||
tabViews=new FrameLayout[3];
|
||||
for(int i=0;i<tabViews.length;i++){
|
||||
FrameLayout tabView=new FrameLayout(getActivity());
|
||||
tabView.setId(switch(i){
|
||||
case 0 -> R.id.notifications_all;
|
||||
case 1 -> R.id.notifications_mentions;
|
||||
case 2 -> R.id.notifications_posts;
|
||||
default -> throw new IllegalStateException("Unexpected value: "+i);
|
||||
});
|
||||
tabView.setVisibility(View.GONE);
|
||||
@@ -101,9 +137,15 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc
|
||||
mentionsFragment=new NotificationsListFragment();
|
||||
mentionsFragment.setArguments(args);
|
||||
|
||||
args=new Bundle(args);
|
||||
args.putBoolean("onlyPosts", true);
|
||||
postsFragment=new NotificationsListFragment();
|
||||
postsFragment.setArguments(args);
|
||||
|
||||
getChildFragmentManager().beginTransaction()
|
||||
.add(R.id.notifications_all, allNotificationsFragment)
|
||||
.add(R.id.notifications_mentions, mentionsFragment)
|
||||
.add(R.id.notifications_posts, postsFragment)
|
||||
.commit();
|
||||
}
|
||||
|
||||
@@ -113,6 +155,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc
|
||||
tab.setText(switch(position){
|
||||
case 0 -> R.string.all_notifications;
|
||||
case 1 -> R.string.mentions;
|
||||
case 2 -> R.string.posts;
|
||||
default -> throw new IllegalStateException("Unexpected value: "+position);
|
||||
});
|
||||
tab.view.textView.setAllCaps(true);
|
||||
@@ -123,12 +166,30 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc
|
||||
return view;
|
||||
}
|
||||
|
||||
public void refreshFollowRequestsBadge() {
|
||||
new GetFollowRequests(null, null, 1).setCallback(new Callback<>() {
|
||||
@Override
|
||||
public void onSuccess(List<Account> accounts) {
|
||||
getToolbar().getMenu().findItem(R.id.follow_requests).setVisible(!accounts.isEmpty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse errorResponse) {}
|
||||
}).exec(accountID);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onFollowRequestHandled(FollowRequestHandledEvent ev) {
|
||||
refreshFollowRequestsBadge();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void scrollToTop(){
|
||||
getFragmentForPage(pager.getCurrentItem()).scrollToTop();
|
||||
}
|
||||
|
||||
public void loadData(){
|
||||
refreshFollowRequestsBadge();
|
||||
if(allNotificationsFragment!=null && !allNotificationsFragment.loaded && !allNotificationsFragment.dataLoading)
|
||||
allNotificationsFragment.loadData();
|
||||
}
|
||||
@@ -143,6 +204,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc
|
||||
return switch(page){
|
||||
case 0 -> allNotificationsFragment;
|
||||
case 1 -> mentionsFragment;
|
||||
case 2 -> postsFragment;
|
||||
default -> throw new IllegalStateException("Unexpected value: "+page);
|
||||
};
|
||||
}
|
||||
@@ -163,7 +225,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc
|
||||
|
||||
@Override
|
||||
public int getItemCount(){
|
||||
return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.squareup.otto.Subscribe;
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.events.NotificationDeletedEvent;
|
||||
import org.joinmastodon.android.events.PollUpdatedEvent;
|
||||
import org.joinmastodon.android.model.Notification;
|
||||
import org.joinmastodon.android.model.PaginatedResponse;
|
||||
@@ -22,6 +23,7 @@ import org.parceler.Parcels;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -33,6 +35,7 @@ import me.grishka.appkit.utils.V;
|
||||
|
||||
public class NotificationsListFragment extends BaseStatusListFragment<Notification>{
|
||||
private boolean onlyMentions;
|
||||
private boolean onlyPosts;
|
||||
private String maxID;
|
||||
|
||||
@Override
|
||||
@@ -51,6 +54,15 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
public void onAttach(Activity activity){
|
||||
super.onAttach(activity);
|
||||
onlyMentions=getArguments().getBoolean("onlyMentions", false);
|
||||
onlyPosts=getArguments().getBoolean("onlyPosts", false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefresh() {
|
||||
super.onRefresh();
|
||||
if (getParentFragment() instanceof NotificationsFragment notificationsFragment) {
|
||||
notificationsFragment.refreshFollowRequestsBadge();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -76,9 +88,11 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
if(titleItem!=null)
|
||||
items.add(0, titleItem);
|
||||
return items;
|
||||
}else{
|
||||
AccountCardStatusDisplayItem card=new AccountCardStatusDisplayItem(n.id, this, n.account);
|
||||
}else if(titleItem!=null){
|
||||
AccountCardStatusDisplayItem card=new AccountCardStatusDisplayItem(n.id, this, n.account, n);
|
||||
return Arrays.asList(titleItem, card);
|
||||
}else{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +108,7 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
protected void doLoadData(int offset, int count){
|
||||
AccountSessionManager.getInstance()
|
||||
.getAccount(accountID).getCacheController()
|
||||
.getNotifications(offset>0 ? maxID : null, count, onlyMentions, refreshing, new SimpleCallback<>(this){
|
||||
.getNotifications(offset>0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing, new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(PaginatedResponse<List<Notification>> result){
|
||||
if(getActivity()==null)
|
||||
@@ -177,4 +191,28 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onNotificationDeleted(NotificationDeletedEvent ev) {
|
||||
Notification notification = getNotificationByID(ev.id);
|
||||
if(notification==null)
|
||||
return;
|
||||
data.remove(notification);
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import static android.content.Context.CLIPBOARD_SERVICE;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
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.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Outline;
|
||||
@@ -14,6 +19,8 @@ import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.VibrationEffect;
|
||||
import android.os.Vibrator;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.ImageSpan;
|
||||
@@ -31,9 +38,11 @@ import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
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;
|
||||
@@ -98,7 +107,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
private CoverImageView cover;
|
||||
private View avatarBorder;
|
||||
private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel, postsCount, postsLabel;
|
||||
private ProgressBarButton actionButton;
|
||||
private ProgressBarButton actionButton, notifyButton;
|
||||
private ViewPager2 pager;
|
||||
private NestedRecyclerScrollView scrollView;
|
||||
private AccountTimelineFragment postsFragment, postsWithRepliesFragment, pinnedPostsFragment, mediaFragment;
|
||||
@@ -109,7 +118,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
private float titleTransY;
|
||||
private View postsBtn, followersBtn, followingBtn;
|
||||
private EditText nameEdit, bioEdit;
|
||||
private ProgressBar actionProgress;
|
||||
private ProgressBar actionProgress, notifyProgress;
|
||||
private FrameLayout[] tabViews;
|
||||
private TabLayoutMediator tabLayoutMediator;
|
||||
private TextView followsYouView;
|
||||
@@ -181,6 +190,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
postsLabel=content.findViewById(R.id.posts_label);
|
||||
postsBtn=content.findViewById(R.id.posts_btn);
|
||||
actionButton=content.findViewById(R.id.profile_action_btn);
|
||||
notifyButton=content.findViewById(R.id.notify_btn);
|
||||
pager=content.findViewById(R.id.pager);
|
||||
scrollView=content.findViewById(R.id.scroller);
|
||||
tabbar=content.findViewById(R.id.tabbar);
|
||||
@@ -188,6 +198,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
nameEdit=content.findViewById(R.id.name_edit);
|
||||
bioEdit=content.findViewById(R.id.bio_edit);
|
||||
actionProgress=content.findViewById(R.id.action_progress);
|
||||
notifyProgress=content.findViewById(R.id.notify_progress);
|
||||
fab=content.findViewById(R.id.fab);
|
||||
followsYouView=content.findViewById(R.id.follows_you);
|
||||
|
||||
@@ -258,6 +269,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
});
|
||||
|
||||
actionButton.setOnClickListener(this::onActionButtonClick);
|
||||
notifyButton.setOnClickListener(this::onNotifyButtonClick);
|
||||
avatar.setOnClickListener(this::onAvatarClick);
|
||||
cover.setOnClickListener(this::onCoverClick);
|
||||
refreshLayout.setOnRefreshListener(this);
|
||||
@@ -414,25 +426,43 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
|
||||
private void bindHeaderView(){
|
||||
setTitle(account.displayName);
|
||||
setSubtitle(getResources().getQuantityString(R.plurals.x_posts, account.statusesCount, account.statusesCount));
|
||||
setSubtitle(getResources().getQuantityString(R.plurals.x_posts, (int)(account.statusesCount%1000), account.statusesCount));
|
||||
ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(100), V.dp(100)));
|
||||
ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.header : account.headerStatic, 1000, 1000));
|
||||
SpannableStringBuilder ssb=new SpannableStringBuilder(account.displayName);
|
||||
HtmlParser.parseCustomEmoji(ssb, account.emojis);
|
||||
name.setText(ssb);
|
||||
setTitle(ssb);
|
||||
|
||||
boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account);
|
||||
|
||||
if(account.locked){
|
||||
ssb=new SpannableStringBuilder("@");
|
||||
ssb.append(account.acct);
|
||||
if(isSelf){
|
||||
ssb.append('@');
|
||||
ssb.append(AccountSessionManager.getInstance().getAccount(accountID).domain);
|
||||
}
|
||||
ssb.append(" ");
|
||||
Drawable lock=username.getResources().getDrawable(R.drawable.ic_fluent_lock_closed_20_filled, getActivity().getTheme()).mutate();
|
||||
Drawable lock=username.getResources().getDrawable(R.drawable.ic_lock, getActivity().getTheme()).mutate();
|
||||
lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight());
|
||||
lock.setTint(username.getCurrentTextColor());
|
||||
ssb.append(getString(R.string.manually_approves_followers), new ImageSpan(lock, ImageSpan.ALIGN_BOTTOM), 0);
|
||||
ssb.append(getString(R.string.manually_approves_followers), new ImageSpan(lock, ImageSpan.ALIGN_BASELINE), 0);
|
||||
username.setText(ssb);
|
||||
}else{
|
||||
username.setText('@'+account.acct);
|
||||
// noinspection SetTextI18n
|
||||
username.setText('@'+account.acct+(isSelf ? ('@'+AccountSessionManager.getInstance().getAccount(accountID).domain) : ""));
|
||||
}
|
||||
username.setOnLongClickListener(l->{
|
||||
ClipboardManager clipboard = (ClipboardManager) getActivity().getSystemService(CLIPBOARD_SERVICE);
|
||||
Vibrator v = (Vibrator) getActivity().getSystemService(Context.VIBRATOR_SERVICE);
|
||||
ClipData clip = ClipData.newPlainText("Username", '@'+account.acct+'@'+AccountSessionManager.getInstance().getAccount(accountID).domain);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
Toast.makeText(getActivity(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) v.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE));
|
||||
else v.vibrate(50);
|
||||
return true;
|
||||
});
|
||||
CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
|
||||
if(TextUtils.isEmpty(parsedBio)){
|
||||
bio.setVisibility(View.GONE);
|
||||
@@ -443,13 +473,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
followersCount.setText(UiUtils.abbreviateNumber(account.followersCount));
|
||||
followingCount.setText(UiUtils.abbreviateNumber(account.followingCount));
|
||||
postsCount.setText(UiUtils.abbreviateNumber(account.statusesCount));
|
||||
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, Math.min(999, account.followersCount)));
|
||||
followingLabel.setText(getResources().getQuantityString(R.plurals.following, Math.min(999, account.followingCount)));
|
||||
postsLabel.setText(getResources().getQuantityString(R.plurals.posts, Math.min(999, account.statusesCount)));
|
||||
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, account.followersCount)));
|
||||
followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, account.followingCount)));
|
||||
postsLabel.setText(getResources().getQuantityString(R.plurals.posts, (int)Math.min(999, account.statusesCount)));
|
||||
|
||||
UiUtils.loadCustomEmojiInTextView(name);
|
||||
UiUtils.loadCustomEmojiInTextView(bio);
|
||||
|
||||
notifyButton.setVisibility(View.GONE);
|
||||
if(AccountSessionManager.getInstance().isSelf(accountID, account)){
|
||||
actionButton.setText(R.string.edit_profile);
|
||||
}else{
|
||||
@@ -519,20 +550,24 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
return;
|
||||
inflater.inflate(R.menu.profile, menu);
|
||||
menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getDisplayUsername()));
|
||||
menu.findItem(R.id.manage_user_lists).setTitle(getString(R.string.lists_with_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 || item.getItemId()==R.id.bookmarks);
|
||||
item.setVisible(item.getItemId()==R.id.share || item.getItemId()==R.id.bookmarks || item.getItemId()==R.id.manage_user_lists);
|
||||
}
|
||||
menu.findItem(R.id.favorites_list).setVisible(true);
|
||||
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()));
|
||||
if(relationship.following)
|
||||
if(relationship.following) {
|
||||
menu.findItem(R.id.hide_boosts).setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
|
||||
else
|
||||
}else {
|
||||
menu.findItem(R.id.hide_boosts).setVisible(false);
|
||||
menu.findItem(R.id.manage_user_lists).setVisible(false);
|
||||
}
|
||||
if(!account.isLocal())
|
||||
menu.findItem(R.id.block_domain).setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
|
||||
else
|
||||
@@ -552,6 +587,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("profileAccount", Parcels.wrap(account));
|
||||
Nav.go(getActivity(), BookmarksListFragment.class, args);
|
||||
}else if(id==R.id.favorites_list) {
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("profileAccount", Parcels.wrap(account));
|
||||
Nav.go(getActivity(), FavoritesListFragment.class, args);
|
||||
}else if(id==R.id.mute){
|
||||
confirmToggleMuted();
|
||||
}else if(id==R.id.block){
|
||||
@@ -569,7 +609,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
updateRelationship();
|
||||
});
|
||||
}else if(id==R.id.hide_boosts){
|
||||
new SetAccountFollowed(account.id, true, !relationship.showingReblogs)
|
||||
new SetAccountFollowed(account.id, true, !relationship.showingReblogs, relationship.notifying)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Relationship result){
|
||||
@@ -583,6 +623,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
})
|
||||
.wrapProgress(getActivity(), R.string.loading, false)
|
||||
.exec(accountID);
|
||||
}else if(id==R.id.manage_user_lists){
|
||||
final Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putString("profileAccount", profileAccountID);
|
||||
args.putString("profileDisplayUsername", account.getDisplayUsername());
|
||||
Nav.go(getActivity(), ListTimelinesFragment.class, args);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -614,9 +660,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
private void updateRelationship(){
|
||||
invalidateOptionsMenu();
|
||||
actionButton.setVisibility(View.VISIBLE);
|
||||
notifyButton.setVisibility(relationship.following ? View.VISIBLE : View.GONE);
|
||||
UiUtils.setRelationshipToActionButton(relationship, actionButton);
|
||||
UiUtils.setRelationshipToActionButton(relationship, notifyButton, true);
|
||||
actionProgress.setIndeterminateTintList(actionButton.getTextColors());
|
||||
notifyProgress.setIndeterminateTintList(notifyButton.getTextColors());
|
||||
followsYouView.setVisibility(relationship.followedBy ? View.VISIBLE : View.GONE);
|
||||
notifyButton.setSelected(relationship.notifying);
|
||||
if (getActivity() != null) notifyButton.setContentDescription(getString(relationship.notifying ? R.string.user_post_notifications_on : R.string.user_post_notifications_off, '@'+account.username));
|
||||
}
|
||||
|
||||
private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){
|
||||
@@ -683,6 +734,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
actionButton.setClickable(!visible);
|
||||
}
|
||||
|
||||
private void setNotifyProgressVisible(boolean visible){
|
||||
notifyButton.setTextVisible(!visible);
|
||||
notifyProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
notifyButton.setClickable(!visible);
|
||||
}
|
||||
|
||||
private void loadAccountInfoAndEnterEditMode(){
|
||||
if(editModeLoading)
|
||||
return;
|
||||
@@ -851,6 +908,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
return Collections.singletonList(att);
|
||||
}
|
||||
|
||||
private void onNotifyButtonClick(View v) {
|
||||
UiUtils.performToggleAccountNotifications(getActivity(), account, accountID, relationship, actionButton, this::setNotifyProgressVisible, this::updateRelationship);
|
||||
}
|
||||
|
||||
private void onAvatarClick(View v){
|
||||
if(isInEditMode){
|
||||
startImagePicker(AVATAR_RESULT);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
@@ -14,15 +15,22 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowManager;
|
||||
import android.view.animation.AlphaAnimation;
|
||||
import android.view.animation.LinearInterpolator;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.PopupMenu;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.Switch;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.squareup.otto.Subscribe;
|
||||
|
||||
import org.joinmastodon.android.BuildConfig;
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
@@ -31,11 +39,13 @@ import org.joinmastodon.android.api.MastodonAPIController;
|
||||
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
|
||||
import org.joinmastodon.android.model.PushNotification;
|
||||
import org.joinmastodon.android.model.PushSubscription;
|
||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.updater.GithubSelfUpdater;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.function.Consumer;
|
||||
@@ -47,7 +57,6 @@ import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.fragments.ToolbarFragment;
|
||||
import me.grishka.appkit.imageloader.ImageCache;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.utils.V;
|
||||
@@ -63,6 +72,7 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
private PushSubscription pushSubscription;
|
||||
|
||||
private ImageView themeTransitionWindowView;
|
||||
private TextItem checkForUpdateItem;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
@@ -73,9 +83,21 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
accountID=getArguments().getString("account");
|
||||
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
|
||||
|
||||
if(GithubSelfUpdater.needSelfUpdating()){
|
||||
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
|
||||
GithubSelfUpdater.UpdateState state=updater.getState();
|
||||
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING){
|
||||
items.add(new UpdateItem());
|
||||
}
|
||||
}
|
||||
|
||||
items.add(new HeaderItem(R.string.settings_theme));
|
||||
items.add(themeItem=new ThemeItem());
|
||||
items.add(new SwitchItem(R.string.theme_true_black, R.drawable.ic_fluent_dark_theme_24_regular, GlobalUserPreferences.trueBlackTheme, this::onTrueBlackThemeChanged));
|
||||
items.add(new SwitchItem(R.string.disable_marquee, R.drawable.ic_fluent_text_more_24_regular, GlobalUserPreferences.disableMarquee, i->{
|
||||
GlobalUserPreferences.disableMarquee=i.checked;
|
||||
GlobalUserPreferences.save();
|
||||
}));
|
||||
|
||||
items.add(new HeaderItem(R.string.settings_behavior));
|
||||
items.add(new SwitchItem(R.string.settings_gif, R.drawable.ic_fluent_gif_24_regular, GlobalUserPreferences.playGifs, i->{
|
||||
@@ -86,6 +108,28 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
GlobalUserPreferences.useCustomTabs=i.checked;
|
||||
GlobalUserPreferences.save();
|
||||
}));
|
||||
items.add(new SwitchItem(R.string.settings_show_interaction_counts, R.drawable.ic_fluent_number_row_24_regular, GlobalUserPreferences.showInteractionCounts, i->{
|
||||
GlobalUserPreferences.showInteractionCounts=i.checked;
|
||||
GlobalUserPreferences.save();
|
||||
}));
|
||||
items.add(new SwitchItem(R.string.settings_always_reveal_content_warnings, R.drawable.ic_fluent_chat_warning_24_regular, GlobalUserPreferences.alwaysExpandContentWarnings, i->{
|
||||
GlobalUserPreferences.alwaysExpandContentWarnings=i.checked;
|
||||
GlobalUserPreferences.save();
|
||||
}));
|
||||
|
||||
items.add(new HeaderItem(R.string.home_timeline));
|
||||
items.add(new SwitchItem(R.string.settings_show_replies, R.drawable.ic_fluent_chat_multiple_24_regular, GlobalUserPreferences.showReplies, i->{
|
||||
GlobalUserPreferences.showReplies=i.checked;
|
||||
GlobalUserPreferences.save();
|
||||
}));
|
||||
items.add(new SwitchItem(R.string.settings_show_boosts, R.drawable.ic_fluent_arrow_repeat_all_24_regular, GlobalUserPreferences.showBoosts, i->{
|
||||
GlobalUserPreferences.showBoosts=i.checked;
|
||||
GlobalUserPreferences.save();
|
||||
}));
|
||||
items.add(new SwitchItem(R.string.settings_load_new_posts, R.drawable.ic_fluent_arrow_up_24_regular, GlobalUserPreferences.loadNewPosts, i->{
|
||||
GlobalUserPreferences.loadNewPosts=i.checked;
|
||||
GlobalUserPreferences.save();
|
||||
}));
|
||||
|
||||
items.add(new HeaderItem(R.string.settings_notifications));
|
||||
items.add(notificationPolicyItem=new NotificationPolicyItem());
|
||||
@@ -97,11 +141,15 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
|
||||
items.add(new HeaderItem(R.string.settings_boring));
|
||||
items.add(new TextItem(R.string.settings_account, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/auth/edit")));
|
||||
items.add(new TextItem(R.string.settings_contribute, ()->UiUtils.launchWebBrowser(getActivity(), "https://github.com/mastodon/mastodon-android")));
|
||||
items.add(new TextItem(R.string.settings_tos, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms")));
|
||||
items.add(new TextItem(R.string.settings_privacy_policy, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms")));
|
||||
|
||||
items.add(new RedHeaderItem(R.string.settings_spicy));
|
||||
if (GithubSelfUpdater.needSelfUpdating()) {
|
||||
checkForUpdateItem = new TextItem(R.string.check_for_update, GithubSelfUpdater.getInstance()::checkForUpdates);
|
||||
items.add(checkForUpdateItem);
|
||||
}
|
||||
items.add(new TextItem(R.string.settings_contribute, ()->UiUtils.launchWebBrowser(getActivity(), "https://github.com/mastodon/mastodon-android")));
|
||||
items.add(new TextItem(R.string.settings_clear_cache, this::clearImageCache));
|
||||
items.add(new TextItem(R.string.log_out, this::confirmLogOut));
|
||||
|
||||
@@ -131,7 +179,7 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
|
||||
// Add 32dp gaps between sections
|
||||
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
|
||||
if((holder instanceof HeaderViewHolder || holder instanceof FooterViewHolder) && holder.getAbsoluteAdapterPosition()>0)
|
||||
if((holder instanceof HeaderViewHolder || holder instanceof FooterViewHolder) && holder.getAbsoluteAdapterPosition()>1)
|
||||
outRect.top=V.dp(32);
|
||||
}
|
||||
});
|
||||
@@ -155,6 +203,20 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
if(GithubSelfUpdater.needSelfUpdating())
|
||||
E.register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView(){
|
||||
super.onDestroyView();
|
||||
if(GithubSelfUpdater.needSelfUpdating())
|
||||
E.unregister(this);
|
||||
}
|
||||
|
||||
private void onThemePreferenceClick(GlobalUserPreferences.ThemePreference theme){
|
||||
GlobalUserPreferences.theme=theme;
|
||||
GlobalUserPreferences.save();
|
||||
@@ -294,6 +356,30 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
});
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
|
||||
checkForUpdateItem.loading = ev.state == GithubSelfUpdater.UpdateState.CHECKING;
|
||||
if (list.findViewHolderForAdapterPosition(items.indexOf(checkForUpdateItem)) instanceof TextViewHolder tvh) tvh.rebind();
|
||||
|
||||
UpdateItem updateItem = null;
|
||||
if(items.get(0) instanceof UpdateItem item0) {
|
||||
updateItem = item0;
|
||||
} else if (ev.state != GithubSelfUpdater.UpdateState.CHECKING
|
||||
&& ev.state != GithubSelfUpdater.UpdateState.NO_UPDATE) {
|
||||
updateItem = new UpdateItem();
|
||||
items.add(0, updateItem);
|
||||
list.setAdapter(new SettingsAdapter());
|
||||
}
|
||||
|
||||
if(updateItem != null && list.findViewHolderForAdapterPosition(0) instanceof UpdateViewHolder uvh){
|
||||
uvh.bind(updateItem);
|
||||
}
|
||||
|
||||
if (ev.state == GithubSelfUpdater.UpdateState.NO_UPDATE) {
|
||||
Toast.makeText(getActivity(), R.string.no_update_available, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private static abstract class Item{
|
||||
public abstract int getViewType();
|
||||
}
|
||||
@@ -358,10 +444,16 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
private class TextItem extends Item{
|
||||
private String text;
|
||||
private Runnable onClick;
|
||||
private boolean loading;
|
||||
|
||||
public TextItem(@StringRes int text, Runnable onClick){
|
||||
public TextItem(@StringRes int text, Runnable onClick) {
|
||||
this(text, onClick, false);
|
||||
}
|
||||
|
||||
public TextItem(@StringRes int text, Runnable onClick, boolean loading){
|
||||
this.text=getString(text);
|
||||
this.onClick=onClick;
|
||||
this.loading=loading;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -395,6 +487,14 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
}
|
||||
}
|
||||
|
||||
private class UpdateItem extends Item{
|
||||
|
||||
@Override
|
||||
public int getViewType(){
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
private class SettingsAdapter extends RecyclerView.Adapter<BindableViewHolder<Item>>{
|
||||
@NonNull
|
||||
@Override
|
||||
@@ -408,6 +508,7 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
case 4 -> new TextViewHolder();
|
||||
case 5 -> new HeaderViewHolder(true);
|
||||
case 6 -> new FooterViewHolder();
|
||||
case 7 -> new UpdateViewHolder();
|
||||
default -> throw new IllegalStateException("Unexpected value: "+viewType);
|
||||
};
|
||||
}
|
||||
@@ -581,14 +682,18 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
|
||||
private class TextViewHolder extends BindableViewHolder<TextItem> implements UsableRecyclerView.Clickable{
|
||||
private final TextView text;
|
||||
private final ProgressBar progress;
|
||||
|
||||
public TextViewHolder(){
|
||||
super(getActivity(), R.layout.item_settings_text, list);
|
||||
text=(TextView) itemView;
|
||||
text = itemView.findViewById(R.id.text);
|
||||
progress = itemView.findViewById(R.id.progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(TextItem item){
|
||||
text.setText(item.text);
|
||||
progress.animate().alpha(item.loading ? 1 : 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -609,4 +714,75 @@ public class SettingsFragment extends MastodonToolbarFragment{
|
||||
text.setText(item.text);
|
||||
}
|
||||
}
|
||||
|
||||
private class UpdateViewHolder extends BindableViewHolder<UpdateItem>{
|
||||
|
||||
private final TextView text;
|
||||
private final Button button;
|
||||
private final ImageButton cancelBtn;
|
||||
private final ProgressBar progress;
|
||||
|
||||
private ObjectAnimator rotationAnimator;
|
||||
private Runnable progressUpdater=this::updateProgress;
|
||||
|
||||
public UpdateViewHolder(){
|
||||
super(getActivity(), R.layout.item_settings_update, list);
|
||||
text=findViewById(R.id.text);
|
||||
button=findViewById(R.id.button);
|
||||
cancelBtn=findViewById(R.id.cancel_btn);
|
||||
progress=findViewById(R.id.progress);
|
||||
button.setOnClickListener(v->{
|
||||
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
|
||||
switch(updater.getState()){
|
||||
case UPDATE_AVAILABLE -> updater.downloadUpdate();
|
||||
case DOWNLOADED -> updater.installUpdate(getActivity());
|
||||
}
|
||||
});
|
||||
cancelBtn.setOnClickListener(v->GithubSelfUpdater.getInstance().cancelDownload());
|
||||
rotationAnimator=ObjectAnimator.ofFloat(progress, View.ROTATION, 0f, 360f);
|
||||
rotationAnimator.setInterpolator(new LinearInterpolator());
|
||||
rotationAnimator.setDuration(1500);
|
||||
rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(UpdateItem item){
|
||||
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
|
||||
GithubSelfUpdater.UpdateState state=updater.getState();
|
||||
if (state == GithubSelfUpdater.UpdateState.CHECKING) return;
|
||||
GithubSelfUpdater.UpdateInfo info=updater.getUpdateInfo();
|
||||
if(state!=GithubSelfUpdater.UpdateState.DOWNLOADED){
|
||||
text.setText(getString(R.string.update_available, info.version));
|
||||
button.setText(getString(R.string.download_update, UiUtils.formatFileSize(getActivity(), info.size, false)));
|
||||
}else{
|
||||
text.setText(getString(R.string.update_ready, info.version));
|
||||
button.setText(R.string.install_update);
|
||||
}
|
||||
if(state==GithubSelfUpdater.UpdateState.DOWNLOADING){
|
||||
rotationAnimator.start();
|
||||
button.setVisibility(View.INVISIBLE);
|
||||
cancelBtn.setVisibility(View.VISIBLE);
|
||||
progress.setVisibility(View.VISIBLE);
|
||||
updateProgress();
|
||||
}else{
|
||||
rotationAnimator.cancel();
|
||||
button.setVisibility(View.VISIBLE);
|
||||
cancelBtn.setVisibility(View.GONE);
|
||||
progress.setVisibility(View.GONE);
|
||||
progress.removeCallbacks(progressUpdater);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateProgress(){
|
||||
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
|
||||
if(updater.getState()!=GithubSelfUpdater.UpdateState.DOWNLOADING)
|
||||
return;
|
||||
int value=Math.round(progress.getMax()*updater.getDownloadProgress());
|
||||
if(Build.VERSION.SDK_INT>=24)
|
||||
progress.setProgress(value, true);
|
||||
else
|
||||
progress.setProgress(value);
|
||||
progress.postDelayed(progressUpdater, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ public class StatusEditHistoryFragment extends StatusListFragment{
|
||||
action=getString(R.string.edit_multiple_changed);
|
||||
}
|
||||
}
|
||||
items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" · "+date, Collections.emptyList(), 0));
|
||||
items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" · "+date, Collections.emptyList(), 0, null));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
|
||||
import org.joinmastodon.android.events.StatusCreatedEvent;
|
||||
import org.joinmastodon.android.events.StatusDeletedEvent;
|
||||
import org.joinmastodon.android.events.StatusUpdatedEvent;
|
||||
import org.joinmastodon.android.events.StatusUnpinnedEvent;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
|
||||
@@ -63,6 +62,59 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
|
||||
|
||||
protected void onStatusCreated(StatusCreatedEvent ev){}
|
||||
|
||||
protected void onStatusUpdated(StatusUpdatedEvent ev){
|
||||
ArrayList<Status> statusesForDisplayItems=new ArrayList<>();
|
||||
for(int i=0;i<data.size();i++){
|
||||
Status s=data.get(i);
|
||||
if(s.reblog!=null && s.reblog.id.equals(ev.status.id)){
|
||||
s.reblog=ev.status;
|
||||
statusesForDisplayItems.add(s);
|
||||
}else if(s.id.equals(ev.status.id)){
|
||||
data.set(i, ev.status);
|
||||
statusesForDisplayItems.add(ev.status);
|
||||
}
|
||||
}
|
||||
for(int i=0;i<preloadedData.size();i++){
|
||||
Status s=preloadedData.get(i);
|
||||
if(s.reblog!=null && s.reblog.id.equals(ev.status.id)){
|
||||
s.reblog=ev.status;
|
||||
}else if(s.id.equals(ev.status.id)){
|
||||
preloadedData.set(i, ev.status);
|
||||
}
|
||||
}
|
||||
|
||||
if(statusesForDisplayItems.isEmpty())
|
||||
return;
|
||||
|
||||
for(Status s:statusesForDisplayItems){
|
||||
int i=0;
|
||||
for(StatusDisplayItem item:displayItems){
|
||||
if(item.parentID.equals(s.id)){
|
||||
int start=i;
|
||||
for(;i<displayItems.size();i++){
|
||||
if(!displayItems.get(i).parentID.equals(s.id))
|
||||
break;
|
||||
}
|
||||
List<StatusDisplayItem> postItems=displayItems.subList(start, i);
|
||||
postItems.clear();
|
||||
postItems.addAll(buildDisplayItems(s));
|
||||
int oldSize=i-start, newSize=postItems.size();
|
||||
if(oldSize==newSize){
|
||||
adapter.notifyItemRangeChanged(start, newSize);
|
||||
}else if(oldSize<newSize){
|
||||
adapter.notifyItemRangeChanged(start, oldSize);
|
||||
adapter.notifyItemRangeInserted(start+oldSize, newSize-oldSize);
|
||||
}else{
|
||||
adapter.notifyItemRangeChanged(start, newSize);
|
||||
adapter.notifyItemRangeRemoved(start+newSize, oldSize-newSize);
|
||||
}
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected Status getContentStatusByID(String id){
|
||||
Status s=getStatusByID(id);
|
||||
return s==null ? null : s.getContentStatus();
|
||||
@@ -138,6 +190,11 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
|
||||
StatusListFragment.this.onStatusCreated(ev);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onStatusUpdated(StatusUpdatedEvent ev){
|
||||
StatusListFragment.this.onStatusUpdated(ev);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onPollUpdated(PollUpdatedEvent ev){
|
||||
if(!ev.accountID.equals(accountID))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -286,6 +286,7 @@ public abstract class BaseAccountListFragment extends BaseRecyclerFragment<BaseA
|
||||
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()));
|
||||
menu.findItem(R.id.manage_user_lists).setTitle(getString(R.string.lists_with_user, account.getDisplayUsername()));
|
||||
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
|
||||
if(relationship.following){
|
||||
hideBoosts.setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
|
||||
@@ -353,7 +354,7 @@ public abstract class BaseAccountListFragment extends BaseRecyclerFragment<BaseA
|
||||
bindRelationship();
|
||||
});
|
||||
}else if(id==R.id.hide_boosts){
|
||||
new SetAccountFollowed(account.id, true, !relationship.showingReblogs)
|
||||
new SetAccountFollowed(account.id, true, !relationship.showingReblogs, relationship.notifying)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Relationship result){
|
||||
|
||||
@@ -12,7 +12,7 @@ public class FollowerListFragment extends AccountRelatedAccountListFragment{
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setSubtitle(getResources().getQuantityString(R.plurals.x_followers, account.followersCount, account.followersCount));
|
||||
setSubtitle(getResources().getQuantityString(R.plurals.x_followers, (int)(account.followersCount%1000), account.followersCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -12,7 +12,7 @@ public class FollowingListFragment extends AccountRelatedAccountListFragment{
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setSubtitle(getResources().getQuantityString(R.plurals.x_following, account.followingCount, account.followingCount));
|
||||
setSubtitle(getResources().getQuantityString(R.plurals.x_following, (int)(account.followingCount%1000), account.followingCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -11,7 +11,7 @@ public class StatusFavoritesListFragment extends StatusRelatedAccountListFragmen
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setTitle(getResources().getQuantityString(R.plurals.x_favorites, status.favouritesCount, status.favouritesCount));
|
||||
setTitle(getResources().getQuantityString(R.plurals.x_favorites, (int)(status.favouritesCount%1000), status.favouritesCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -11,7 +11,7 @@ public class StatusReblogsListFragment extends StatusRelatedAccountListFragment{
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setTitle(getResources().getQuantityString(R.plurals.x_reblogs, status.reblogsCount, status.reblogsCount));
|
||||
setTitle(getResources().getQuantityString(R.plurals.x_reblogs, (int)(status.reblogsCount%1000), status.reblogsCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -220,9 +220,9 @@ public class DiscoverAccountsFragment extends BaseRecyclerFragment<DiscoverAccou
|
||||
followersCount.setText(UiUtils.abbreviateNumber(item.account.followersCount));
|
||||
followingCount.setText(UiUtils.abbreviateNumber(item.account.followingCount));
|
||||
postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount));
|
||||
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, Math.min(999, item.account.followersCount)));
|
||||
followingLabel.setText(getResources().getQuantityString(R.plurals.following, Math.min(999, item.account.followingCount)));
|
||||
postsLabel.setText(getResources().getQuantityString(R.plurals.posts, Math.min(999, item.account.statusesCount)));
|
||||
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount)));
|
||||
followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount)));
|
||||
postsLabel.setText(getResources().getQuantityString(R.plurals.posts, (int)Math.min(999, item.account.statusesCount)));
|
||||
relationship=relationships.get(item.account.id);
|
||||
if(relationship==null){
|
||||
actionWrap.setVisibility(View.GONE);
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.fragments.ScrollableToTop;
|
||||
import org.joinmastodon.android.fragments.ListTimelinesFragment;
|
||||
import org.joinmastodon.android.ui.SimpleViewHolder;
|
||||
import org.joinmastodon.android.ui.tabs.TabLayout;
|
||||
import org.joinmastodon.android.ui.tabs.TabLayoutMediator;
|
||||
@@ -52,6 +53,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
private SearchFragment searchFragment;
|
||||
private LocalTimelineFragment localTimelineFragment;
|
||||
private FederatedTimelineFragment federatedTimelineFragment;
|
||||
private ListTimelinesFragment listTimelinesFragment;
|
||||
|
||||
private String accountID;
|
||||
private Runnable searchDebouncer=this::onSearchChangedDebounced;
|
||||
@@ -73,7 +75,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
tabLayout=view.findViewById(R.id.tabbar);
|
||||
pager=view.findViewById(R.id.pager);
|
||||
|
||||
tabViews=new FrameLayout[6];
|
||||
tabViews=new FrameLayout[7];
|
||||
for(int i=0;i<tabViews.length;i++){
|
||||
FrameLayout tabView=new FrameLayout(getActivity());
|
||||
tabView.setId(switch(i){
|
||||
@@ -83,6 +85,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
case 3 -> R.id.discover_posts;
|
||||
case 4 -> R.id.discover_news;
|
||||
case 5 -> R.id.discover_users;
|
||||
case 6 -> R.id.discover_lists;
|
||||
default -> throw new IllegalStateException("Unexpected value: "+i);
|
||||
});
|
||||
tabView.setVisibility(View.GONE);
|
||||
@@ -131,6 +134,9 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
federatedTimelineFragment=new FederatedTimelineFragment();
|
||||
federatedTimelineFragment.setArguments(args);
|
||||
|
||||
listTimelinesFragment=new ListTimelinesFragment();
|
||||
listTimelinesFragment.setArguments(args);
|
||||
|
||||
getChildFragmentManager().beginTransaction()
|
||||
.add(R.id.discover_posts, postsFragment)
|
||||
.add(R.id.discover_local_timeline, localTimelineFragment)
|
||||
@@ -138,6 +144,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
.add(R.id.discover_hashtags, hashtagsFragment)
|
||||
.add(R.id.discover_news, newsFragment)
|
||||
.add(R.id.discover_users, accountsFragment)
|
||||
.add(R.id.discover_lists, listTimelinesFragment)
|
||||
.commit();
|
||||
}
|
||||
|
||||
@@ -151,6 +158,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
case 3 -> R.string.posts;
|
||||
case 4 -> R.string.news;
|
||||
case 5 -> R.string.for_you;
|
||||
case 6 -> R.string.list_timelines;
|
||||
default -> throw new IllegalStateException("Unexpected value: "+position);
|
||||
});
|
||||
tab.view.textView.setAllCaps(true);
|
||||
@@ -279,6 +287,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
case 3 -> postsFragment;
|
||||
case 4 -> newsFragment;
|
||||
case 5 -> accountsFragment;
|
||||
case 6 -> listTimelinesFragment;
|
||||
default -> throw new IllegalStateException("Unexpected value: "+page);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -91,6 +93,8 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
|
||||
@Override
|
||||
public void onItemClick(String id){
|
||||
SearchResult res=getResultByID(id);
|
||||
if(res==null)
|
||||
return;
|
||||
switch(res.type){
|
||||
case ACCOUNT -> {
|
||||
Bundle args=new Bundle();
|
||||
@@ -98,7 +102,7 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
|
||||
args.putParcelable("profileAccount", Parcels.wrap(res.account));
|
||||
Nav.go(getActivity(), ProfileFragment.class, args);
|
||||
}
|
||||
case HASHTAG -> UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag.name);
|
||||
case HASHTAG -> UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag.name, res.hashtag.following);
|
||||
case STATUS -> {
|
||||
Status status=res.status.getContentStatus();
|
||||
Bundle args=new Bundle();
|
||||
@@ -126,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<>();
|
||||
@@ -146,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);
|
||||
}
|
||||
@@ -157,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> impl
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
UiUtils.openHashtagTimeline(getActivity(), accountID, item.name);
|
||||
UiUtils.openHashtagTimeline(getActivity(), accountID, item.name, item.following);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package org.joinmastodon.android.fragments.onboarding;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowInsets;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
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.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
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.imageloader.ViewImageLoader;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.utils.MergeRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
public class GoogleMadeMeAddThisFragment extends AppKitFragment{
|
||||
private UsableRecyclerView list;
|
||||
private MergeRecyclerAdapter adapter;
|
||||
private Button btn;
|
||||
private View buttonBar;
|
||||
private Instance instance;
|
||||
private ArrayList<Item> items=new ArrayList<>();
|
||||
private Call currentRequest;
|
||||
private ItemsAdapter itemsAdapter;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setRetainInstance(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Activity activity){
|
||||
super.onAttach(activity);
|
||||
setNavigationBarColor(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground));
|
||||
instance=Parcels.unwrap(getArguments().getParcelable("instance"));
|
||||
|
||||
items.add(new Item("Mastodon for Android Privacy Policy", "joinmastodon.org", "https://joinmastodon.org/android/privacy", "https://joinmastodon.org/favicon-32x32.png"));
|
||||
loadServerPrivacyPolicy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy(){
|
||||
super.onDestroy();
|
||||
if(currentRequest!=null){
|
||||
currentRequest.cancel();
|
||||
currentRequest=null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight));
|
||||
}
|
||||
|
||||
protected void onButtonClick(){
|
||||
Bundle args=new Bundle();
|
||||
args.putParcelable("instance", Parcels.wrap(instance));
|
||||
Nav.go(getActivity(), SignupFragment.class, args);
|
||||
}
|
||||
|
||||
@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);
|
||||
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0));
|
||||
}else{
|
||||
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
|
||||
}
|
||||
}
|
||||
|
||||
private void loadServerPrivacyPolicy(){
|
||||
Request req=new Request.Builder()
|
||||
.url("https://"+instance.uri+"/terms")
|
||||
.addHeader("Accept-Language", Locale.getDefault().toLanguageTag())
|
||||
.build();
|
||||
currentRequest=MastodonAPIController.getHttpClient().newCall(req);
|
||||
currentRequest.enqueue(new Callback(){
|
||||
@Override
|
||||
public void onFailure(@NonNull Call call, @NonNull IOException e){
|
||||
currentRequest=null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{
|
||||
currentRequest=null;
|
||||
try(ResponseBody body=response.body()){
|
||||
if(!response.isSuccessful())
|
||||
return;
|
||||
Document doc=Jsoup.parse(Objects.requireNonNull(body).byteStream(), Objects.requireNonNull(body.contentType()).charset(StandardCharsets.UTF_8).name(), req.url().toString());
|
||||
final Item item=new Item(doc.title(), instance.uri, req.url().toString(), "https://"+instance.uri+"/favicon.ico");
|
||||
Activity activity=getActivity();
|
||||
if(activity!=null){
|
||||
activity.runOnUiThread(()->{
|
||||
items.add(item);
|
||||
itemsAdapter.notifyItemInserted(items.size()-1);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private class ItemsAdapter extends RecyclerView.Adapter<ItemViewHolder>{
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
return new ItemViewHolder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ItemViewHolder holder, int position){
|
||||
holder.bind(items.get(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount(){
|
||||
return items.size();
|
||||
}
|
||||
}
|
||||
|
||||
private class ItemViewHolder extends BindableViewHolder<Item> implements UsableRecyclerView.Clickable{
|
||||
private final TextView domain, title;
|
||||
private final ImageView favicon;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(Item item){
|
||||
domain.setText(item.domain);
|
||||
title.setText(item.title);
|
||||
|
||||
ViewImageLoader.load(favicon, null, new UrlImageLoaderRequest(item.faviconUrl));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
UiUtils.launchWebBrowser(getActivity(), item.url);
|
||||
}
|
||||
}
|
||||
|
||||
private static class Item{
|
||||
public String title, domain, url, faviconUrl;
|
||||
|
||||
public Item(String title, String domain, String url, String faviconUrl){
|
||||
this.title=title;
|
||||
this.domain=domain;
|
||||
this.url=url;
|
||||
this.faviconUrl=faviconUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.joinmastodon.android.fragments.onboarding;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
@@ -9,6 +10,7 @@ import android.os.LocaleList;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.Log;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -21,6 +23,7 @@ import android.widget.RadioButton;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.MastodonAPIController;
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.api.MastodonErrorResponse;
|
||||
import org.joinmastodon.android.api.requests.instance.GetInstance;
|
||||
@@ -36,7 +39,13 @@ 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.Node;
|
||||
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,8 +53,11 @@ 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;
|
||||
@@ -58,6 +70,9 @@ 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;
|
||||
@@ -75,9 +90,11 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
private List<CatalogCategory> categories=new ArrayList<>();
|
||||
private String loadingInstanceDomain;
|
||||
private GetInstance loadingInstanceRequest;
|
||||
private Call loadingInstanceRedirectRequest;
|
||||
private HashMap<String, Instance> instancesCache=new HashMap<>();
|
||||
private ProgressDialog instanceProgressDialog;
|
||||
private View buttonBar;
|
||||
private HashMap<String, String> redirects=new HashMap<>(), redirectsInverse=new HashMap<>();
|
||||
|
||||
private boolean isSignup;
|
||||
|
||||
@@ -271,7 +288,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
}else{
|
||||
showProgressDialog();
|
||||
if(!domain.equals(loadingInstanceDomain)){
|
||||
loadInstanceInfo(domain);
|
||||
loadInstanceInfo(domain, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,6 +296,14 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
private void proceedWithAuthOrSignup(Instance instance){
|
||||
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0);
|
||||
if(isSignup){
|
||||
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);
|
||||
@@ -353,7 +378,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
Instance instance=instancesCache.get(normalizeInstanceDomain(currentSearchQuery));
|
||||
if(instance==null){
|
||||
showProgressDialog();
|
||||
loadInstanceInfo(currentSearchQuery);
|
||||
loadInstanceInfo(currentSearchQuery, false);
|
||||
}else{
|
||||
proceedWithAuthOrSignup(instance);
|
||||
}
|
||||
@@ -363,7 +388,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
private void onSearchChangedDebounced(){
|
||||
currentSearchQuery=searchEdit.getText().toString().toLowerCase();
|
||||
updateFilteredList();
|
||||
loadInstanceInfo(currentSearchQuery);
|
||||
loadInstanceInfo(currentSearchQuery, false);
|
||||
}
|
||||
|
||||
private void updateFilteredList(){
|
||||
@@ -403,13 +428,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
private 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();
|
||||
}
|
||||
|
||||
@@ -429,10 +448,12 @@ 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){
|
||||
private void loadInstanceInfo(String _domain, boolean isFromRedirect){
|
||||
String domain=normalizeInstanceDomain(_domain);
|
||||
Instance cachedInstance=instancesCache.get(domain);
|
||||
if(cachedInstance!=null){
|
||||
@@ -446,10 +467,11 @@ 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();
|
||||
@@ -465,7 +487,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
instanceProgressDialog=null;
|
||||
proceedWithAuthOrSignup(result);
|
||||
}
|
||||
if(domain.equals(currentSearchQuery)){
|
||||
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)){
|
||||
@@ -484,20 +506,105 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
@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();
|
||||
if(!isFromRedirect && error instanceof MastodonErrorResponse me && me.httpStatus==404){
|
||||
fetchDomainFromHostMetaAndMaybeRetry(domain, error);
|
||||
return;
|
||||
}
|
||||
loadingInstanceDomain=null;
|
||||
showInstanceInfoLoadError(domain, error);
|
||||
}
|
||||
}).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
|
||||
public void onApplyWindowInsets(WindowInsets insets){
|
||||
if(Build.VERSION.SDK_INT>=27){
|
||||
@@ -580,7 +687,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
if(chosenInstance==null)
|
||||
nextButton.setEnabled(true);
|
||||
chosenInstance=item;
|
||||
loadInstanceInfo(chosenInstance.domain);
|
||||
loadInstanceInfo(chosenInstance.domain, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ public class InstanceRulesFragment extends AppKitFragment{
|
||||
protected void onButtonClick(){
|
||||
Bundle args=new Bundle();
|
||||
args.putParcelable("instance", Parcels.wrap(instance));
|
||||
Nav.go(getActivity(), SignupFragment.class, args);
|
||||
Nav.go(getActivity(), GoogleMadeMeAddThisFragment.class, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -200,7 +200,6 @@ public class SignupFragment extends AppKitFragment{
|
||||
@Override
|
||||
public void onSuccess(Token result){
|
||||
progressDialog.dismiss();
|
||||
progressDialog=null;
|
||||
Account fakeAccount=new Account();
|
||||
fakeAccount.acct=fakeAccount.username=username;
|
||||
fakeAccount.id="tmp"+System.currentTimeMillis();
|
||||
@@ -238,7 +237,6 @@ public class SignupFragment extends AppKitFragment{
|
||||
error.showToast(getActivity());
|
||||
}
|
||||
progressDialog.dismiss();
|
||||
progressDialog=null;
|
||||
}
|
||||
})
|
||||
.exec(instance.uri, apiToken);
|
||||
@@ -255,9 +253,11 @@ public class SignupFragment extends AppKitFragment{
|
||||
}
|
||||
|
||||
private void showProgressDialog(){
|
||||
progressDialog=new ProgressDialog(getActivity());
|
||||
progressDialog.setMessage(getString(R.string.loading));
|
||||
progressDialog.setCancelable(false);
|
||||
if(progressDialog==null){
|
||||
progressDialog=new ProgressDialog(getActivity());
|
||||
progressDialog.setMessage(getString(R.string.loading));
|
||||
progressDialog.setCancelable(false);
|
||||
}
|
||||
progressDialog.show();
|
||||
}
|
||||
|
||||
@@ -280,7 +280,6 @@ public class SignupFragment extends AppKitFragment{
|
||||
if(submitAfterGettingToken){
|
||||
submitAfterGettingToken=false;
|
||||
progressDialog.dismiss();
|
||||
progressDialog=null;
|
||||
error.showToast(getActivity());
|
||||
}
|
||||
}
|
||||
@@ -307,7 +306,6 @@ public class SignupFragment extends AppKitFragment{
|
||||
if(submitAfterGettingToken){
|
||||
submitAfterGettingToken=false;
|
||||
progressDialog.dismiss();
|
||||
progressDialog=null;
|
||||
error.showToast(getActivity());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
|
||||
else
|
||||
selectedIDs.add(id);
|
||||
list.invalidate();
|
||||
btn.setEnabled(!selectedIDs.isEmpty());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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){
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user