Compare commits
350 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b40643e63 | ||
|
|
5968dcd05b | ||
|
|
0c382bdbf6 | ||
|
|
74f03026cf | ||
|
|
8ad6bd52ef | ||
|
|
04da21edf3 | ||
|
|
ee709b6db7 | ||
|
|
b7432fe422 | ||
|
|
8a0991d533 | ||
|
|
6fffe778d3 | ||
|
|
b67b61dfe4 | ||
|
|
65d86d9238 | ||
|
|
b2db64022f | ||
|
|
8336bfdf5c | ||
|
|
b38bf5e431 | ||
|
|
310fb7db42 | ||
|
|
2f24977996 | ||
|
|
6c336ba89e | ||
|
|
d1b1cb2082 | ||
|
|
5bbe99be51 | ||
|
|
556d1e7433 | ||
|
|
293d7032ce | ||
|
|
48e7071450 | ||
|
|
bcc8d55c7b | ||
|
|
8d477efc28 | ||
|
|
4ae21862a5 | ||
|
|
6e6aebef35 | ||
|
|
cd138032da | ||
|
|
864e6fb9d0 | ||
|
|
9d356b0635 | ||
|
|
6c5d720a40 | ||
|
|
d733d76ccf | ||
|
|
3483d8c3c0 | ||
|
|
0f326c1362 | ||
|
|
c6eda38400 | ||
|
|
3c59c8cc0f | ||
|
|
8f1b9ec092 | ||
|
|
5a42136395 | ||
|
|
e9b347d130 | ||
|
|
d86e203127 | ||
|
|
c80417e671 | ||
|
|
55a55fbb1c | ||
|
|
b42236999b | ||
|
|
8ca8bd765b | ||
|
|
7f4cf77283 | ||
|
|
950d413bd1 | ||
|
|
475827b1c1 | ||
|
|
1c9164e559 | ||
|
|
7311a394d8 | ||
|
|
aa09bc7ab2 | ||
|
|
267a6a75ef | ||
|
|
37660b4c73 | ||
|
|
15b4d46ea1 | ||
|
|
0b99e76b25 | ||
|
|
c851f666b3 | ||
|
|
7662c81754 | ||
|
|
5c1b583448 | ||
|
|
4071c1552d | ||
|
|
89856a81a3 | ||
|
|
005ddfb651 | ||
|
|
92d10d59c6 | ||
|
|
150f70edd8 | ||
|
|
73019eaade | ||
|
|
46bac59ff5 | ||
|
|
9b162cb63b | ||
|
|
7d216314c9 | ||
|
|
080a320e12 | ||
|
|
28787b4068 | ||
|
|
a73ea62a9c | ||
|
|
69b399e397 | ||
|
|
fc2c033e93 | ||
|
|
1d81abca5b | ||
|
|
0f3cd5d8d0 | ||
|
|
f0476f3187 | ||
|
|
b4677d14e5 | ||
|
|
a8837bd4f8 | ||
|
|
c6991a7067 | ||
|
|
0723e942f0 | ||
|
|
52fd300d1e | ||
|
|
37cefcaf6d | ||
|
|
68c9f7a861 | ||
|
|
8eb0b12a09 | ||
|
|
558adc6936 | ||
|
|
68ecd7bc28 | ||
|
|
5c7d4e389f | ||
|
|
55fd74c227 | ||
|
|
b65b7c53bc | ||
|
|
68d0862008 | ||
|
|
c9e13eefa5 | ||
|
|
349fbce5af | ||
|
|
7a23c9b348 | ||
|
|
95c66654aa | ||
|
|
a8407571a4 | ||
|
|
75538deb9b | ||
|
|
601eec4607 | ||
|
|
9b87d0bece | ||
|
|
cb25632691 | ||
|
|
63957250c5 | ||
|
|
32cc760272 | ||
|
|
e105764aa8 | ||
|
|
5a9a352e56 | ||
|
|
7deb2d452e | ||
|
|
c3d2df88e8 | ||
|
|
9943d19c31 | ||
|
|
51c1e115c5 | ||
|
|
f83ff93c68 | ||
|
|
7deb5d44c2 | ||
|
|
3140ae8046 | ||
|
|
772f79219b | ||
|
|
8830d67af0 | ||
|
|
89c02be41c | ||
|
|
1d092c660b | ||
|
|
a34084da5a | ||
|
|
212f5a9beb | ||
|
|
f6333de4e6 | ||
|
|
5af22f1bab | ||
|
|
02d866d7d6 | ||
|
|
fa7aa6240b | ||
|
|
cb38e0d367 | ||
|
|
a133a1d01f | ||
|
|
79bfc43431 | ||
|
|
72f3a51af7 | ||
|
|
ee73b487ae | ||
|
|
e580d2e890 | ||
|
|
88504531d4 | ||
|
|
4f6f53061f | ||
|
|
be23ec4176 | ||
|
|
186636c2ef | ||
|
|
4ad9fa030b | ||
|
|
f0396ff418 | ||
|
|
67e793b56a | ||
|
|
d84f011d27 | ||
|
|
f685d9ccdd | ||
|
|
380c742f54 | ||
|
|
ed84ea6162 | ||
|
|
b4219bcaa0 | ||
|
|
e99ffc0d4c | ||
|
|
ca015db188 | ||
|
|
af3a3761f2 | ||
|
|
95085b6306 | ||
|
|
1a0435d32c | ||
|
|
bd2a33da6a | ||
|
|
84e8b08bff | ||
|
|
afc40cbb67 | ||
|
|
6902379af6 | ||
|
|
450bfa1fe8 | ||
|
|
629b1b4a34 | ||
|
|
8f49207b25 | ||
|
|
347d90ad14 | ||
|
|
b503475fcf | ||
|
|
fe18a43ba8 | ||
|
|
66e23bf55e | ||
|
|
716b6b13b7 | ||
|
|
23ec3e64cf | ||
|
|
e512a7ef90 | ||
|
|
9823537474 | ||
|
|
f9ea2b0de3 | ||
|
|
90293f81d9 | ||
|
|
30785457b7 | ||
|
|
df5cb3d977 | ||
|
|
bbedf46b21 | ||
|
|
0d50f8c45b | ||
|
|
d4e4d9fcde | ||
|
|
50381f1256 | ||
|
|
55a6b7bdd3 | ||
|
|
c9eac418d2 | ||
|
|
fa75570254 | ||
|
|
a51bcba87b | ||
|
|
1406ea376d | ||
|
|
4f4212124c | ||
|
|
da773dfac9 | ||
|
|
35185143a2 | ||
|
|
f12a33a749 | ||
|
|
77a2a5a629 | ||
|
|
d09302492e | ||
|
|
ab5895b21c | ||
|
|
26360613b1 | ||
|
|
bd020f077f | ||
|
|
35622f3675 | ||
|
|
7516bdf2e8 | ||
|
|
d07e765873 | ||
|
|
510c97a552 | ||
|
|
6d78a43bfe | ||
|
|
6ac880828e | ||
|
|
2eb01ed477 | ||
|
|
c32ca51fa5 | ||
|
|
998e560835 | ||
|
|
46325f46c1 | ||
|
|
9f1d82ed12 | ||
|
|
24c5a2bf6c | ||
|
|
050de32cae | ||
|
|
1d295ca058 | ||
|
|
1779c132cd | ||
|
|
f70fcb8ff8 | ||
|
|
a4878b427e | ||
|
|
fcc73b5877 | ||
|
|
8e5bf91a01 | ||
|
|
4faa8cf7a8 | ||
|
|
afe8fd89e4 | ||
|
|
4df4528e60 | ||
|
|
ff99430f4c | ||
|
|
f4026f09a0 | ||
|
|
ea9a2047f6 | ||
|
|
c16d373de8 | ||
|
|
5562bf936e | ||
|
|
02a1f2ef8c | ||
|
|
7b26649521 | ||
|
|
e34542a420 | ||
|
|
a0007f2e41 | ||
|
|
e5067e8982 | ||
|
|
779d93b689 | ||
|
|
397f67af10 | ||
|
|
027c4e0e59 | ||
|
|
c04278754e | ||
|
|
daba0836e0 | ||
|
|
52307de614 | ||
|
|
6f8ce04c48 | ||
|
|
144efdffee | ||
|
|
8059120136 | ||
|
|
7819f10b8b | ||
|
|
999c2e4714 | ||
|
|
ec38210dde | ||
|
|
1ebb5ad46d | ||
|
|
004d7a7652 | ||
|
|
6b68bd58f1 | ||
|
|
775ae68314 | ||
|
|
347a53f03f | ||
|
|
690792ed0d | ||
|
|
6ee44edf84 | ||
|
|
fc2ba241a0 | ||
|
|
a1efdd7e03 | ||
|
|
a79b0a4f15 | ||
|
|
69b4cf93a3 | ||
|
|
e207b5929d | ||
|
|
03e08dddf5 | ||
|
|
ad60646b8e | ||
|
|
51938f5522 | ||
|
|
38eadca4e2 | ||
|
|
0fb1b3228f | ||
|
|
5a8ebdb13b | ||
|
|
046a45a25e | ||
|
|
751028326a | ||
|
|
74e049884b | ||
|
|
31cb17d549 | ||
|
|
3b63ca1b55 | ||
|
|
3eed854909 | ||
|
|
10a5bf0a82 | ||
|
|
a58a279e8c | ||
|
|
0fe58e49b6 | ||
|
|
e4b187acd6 | ||
|
|
9a95944adb | ||
|
|
21e441d683 | ||
|
|
089e297656 | ||
|
|
93906ecf08 | ||
|
|
cdb836742e | ||
|
|
80cff031d7 | ||
|
|
cea17b22cb | ||
|
|
97bf165e9e | ||
|
|
36345582c7 | ||
|
|
940a4a9ce7 | ||
|
|
8362bca6bf | ||
|
|
09ef005d0e | ||
|
|
5ec1ec26b7 | ||
|
|
3ee159a4a5 | ||
|
|
084b0d3a0c | ||
|
|
b5692c1ddc | ||
|
|
e986a7f023 | ||
|
|
367843d12b | ||
|
|
40186b0025 | ||
|
|
2a65bdb08f | ||
|
|
93fbc52f6a | ||
|
|
6df0333d97 | ||
|
|
4e4b5fcfe4 | ||
|
|
11363d6dea | ||
|
|
3e5d369004 | ||
|
|
b3fd81ce26 | ||
|
|
68d4eae53f | ||
|
|
01e8a9026b | ||
|
|
b0039926e5 | ||
|
|
86ec53c4dc | ||
|
|
b5d57998ae | ||
|
|
1c77c6308e | ||
|
|
acee26a573 | ||
|
|
4a5f20c073 | ||
|
|
cebef82c83 | ||
|
|
620bc2285c | ||
|
|
f73849dbb7 | ||
|
|
2c12e8bc2f | ||
|
|
4e0a0a5065 | ||
|
|
d80f6a1c2c | ||
|
|
8081d5fa1a | ||
|
|
d7c56b52ac | ||
|
|
e8eb12532a | ||
|
|
e95d0c9914 | ||
|
|
f1fd12639e | ||
|
|
3009d7e6fa | ||
|
|
2438dfde2a | ||
|
|
28e8332b67 | ||
|
|
29bd34ab2b | ||
|
|
6f2e8237de | ||
|
|
e2308fcb5d | ||
|
|
8054084537 | ||
|
|
ec8f2dbdf4 | ||
|
|
80bc1d8339 | ||
|
|
c1b28bde6b | ||
|
|
7c3b5c4a15 | ||
|
|
eac0fdbcbf | ||
|
|
1a129ad684 | ||
|
|
583758b231 | ||
|
|
254bc8c0ab | ||
|
|
9a0c383da8 | ||
|
|
97843d5ca1 | ||
|
|
f2eac28006 | ||
|
|
d0eae2d17f | ||
|
|
6c4d9a1d0f | ||
|
|
fc9e38ea24 | ||
|
|
7c07d521f3 | ||
|
|
73d7c40cdd | ||
|
|
ba3871fc2d | ||
|
|
f1f14b765a | ||
|
|
034a4b501a | ||
|
|
2db10585d5 | ||
|
|
cb6bd4180b | ||
|
|
cd099fc17e | ||
|
|
8136a9af63 | ||
|
|
dd67d9d078 | ||
|
|
3fcab4122c | ||
|
|
fac79bbeaa | ||
|
|
c21061e0a7 | ||
|
|
0f3421296d | ||
|
|
c6a8bd96bc | ||
|
|
9201760103 | ||
|
|
f0c521ea95 | ||
|
|
aceb89242e | ||
|
|
4ff2f369f6 | ||
|
|
226ac8303c | ||
|
|
5601554051 | ||
|
|
f06492de56 | ||
|
|
f70f2af973 | ||
|
|
321fc5aa25 | ||
|
|
178207026f | ||
|
|
dcb96dafeb | ||
|
|
6d807e967f | ||
|
|
10df38d9b1 | ||
|
|
69c0873c8f | ||
|
|
b04e328a53 | ||
|
|
fb5afae720 | ||
|
|
6875f40480 | ||
|
|
1ed79d2355 | ||
|
|
287e5fc058 |
@@ -1,4 +1,5 @@
|
||||
# Mastodon for Android
|
||||
[](https://crowdin.com/project/mastodon-for-android)
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=org.joinmastodon.android"><img src="img/google-play-badge.png" height="50"></a>
|
||||
|
||||
|
||||
16
fastlane/metadata/android/ar-SA/full_description.txt
Normal file
16
fastlane/metadata/android/ar-SA/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
ماستودون هي أكبر شبكة اجتماعية لا مركزيَّة على الإنترنت. بدلاً من كونها على موقع ويب واحد مركزي، هي عبارة عن شبكة من ملايين المستخدمين في مجتمعات مُستقلَّة يمكنهم جميعًا التفاعل مع بعضهم البعض بسلاسة. بغض النظر عن اهتماماتك، يمكنك مقابلة أشخاص متحمسين ينشرون عنها في ماستودون!
|
||||
|
||||
اِنضم إلَى مُجتَمع وأنشئ مِلَفَّكَ التَّعريفِيّ. ابحث عن أشخاص رائعين، تابعهم واقرأ منشوراتهم في خطٍّ زمني خالٍ من الإعلانات. عبِّر عَن نَفسِكَ باِستخدام رُموزٍ تَعبيرِيَّةٍ مُخصَّصَة، أو صُوَر، أو صُوَرٍ مُتحَرِّكَة، أو مَقاطِعٍ مَرئِّيَة أو مَقاطِعٍ صَوتِيَّةٍ فِي مَنشوراتٍ ذَاتُ خَمسِمائَة حَرف. رُدّ على سَلاسِلِ المَنشوراتِ، وأعِد تَدوينَ مَنشُوراتِ أيِّ شَخصٍ لِمُشارَكَةِ الأُمُورِ الرَّائِعَة. اِبحَث عَن حِساباتٍ جَديدَةٍ لِمُتابَعَتِها، وَعَن وُسُومٍ شَائِعَةٍ لِتَوسيعِ شَبَكَتِك.
|
||||
|
||||
ماستودون مبني بتركيز على الأمان والخصوصيَّة. حدِّد ما إذا أردتَ مُشارَكَةَ مَنشُوراتِكَ مَعَ مُتابِعيك، أو الأشخاصِ الَّذينَ أشَرتَ إليهِم فَقَط أو العالَمَ بأسرِه. تتيح لك تحذيرات المحتوى إخفاء المنشورات التي تحتوي على مواد حساسة أو محفِّزَة حتى تكون مستعد للتفاعل مع محتواها. لكل مجتمع إرشاداته الخاصة ومشرفيه الخاصين للحفاظ على أمان أعضائه، كما تُساعد أدوات الحظر والإبلاغ القوية في منع إساءة الاستخدام.
|
||||
|
||||
مَزيدٌ مِنَ المَزايَا:
|
||||
|
||||
• النمط الداكِن: قراءة المنشورات في النمط المضيء، الداكِن أو الأسود الحقيقي
|
||||
• استطلاعات الرأي: اسأل المُتابعين عن آرائِهِم وسَتُسجَّل الأصوات
|
||||
• الاستكشاف: الأوسِمَة والحِسابات الرائجة على بُعد نقرة واحِدَة
|
||||
• الإشعارات: احصل على الجديد بشأن المُتابعات، الرُدود وعمليات إعادة التدوين
|
||||
• المشاركة: انشر مباشرة على ماستودون من أي لوح مُشاركة في أي تطبيق
|
||||
• الجاذبية: جالب الحظ لدينا هو فيل رائع، سَتراه يظهر فجأة في السطح بين الفينة والأُخرى
|
||||
|
||||
مَاستودُون هي مُنَظَّمَةُ غَيرُ رِبحِيَّةٍ مُسَجَّلَة. مُساهَمَاتُكَ هِي الدَّاعِمُ المُباشِرُ لعَمَلِيَّةِ التَّطوير. لا توجد إعلانات، لا تسييل ولا رأس مال استثماري، نحن نخطط للبقاء على هذا النحو.
|
||||
1
fastlane/metadata/android/ar-SA/short_description.txt
Normal file
1
fastlane/metadata/android/ar-SA/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
شَبَكةٌ اِجتِماعِيَّةٌ لَا مَركزِيَّة
|
||||
1
fastlane/metadata/android/ar-SA/title.txt
Normal file
1
fastlane/metadata/android/ar-SA/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
مَاستودُون
|
||||
16
fastlane/metadata/android/cs-CZ/full_description.txt
Normal file
16
fastlane/metadata/android/cs-CZ/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/cs-CZ/short_description.txt
Normal file
1
fastlane/metadata/android/cs-CZ/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Decentralized social network
|
||||
1
fastlane/metadata/android/cs-CZ/title.txt
Normal file
1
fastlane/metadata/android/cs-CZ/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
@@ -1,8 +1,8 @@
|
||||
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!
|
||||
|
||||
Trete einer Gemeinschaft bei und erstelle dein Profil. Finde und verfolge faszinierende Leute und lese 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, 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.
|
||||
|
||||
Mastodon wurde mit einem Schwerpunkt auf Privatsphäre und Sicherheit gebaut. Entscheide, ob du deine Beiträge mit deinen Followern, nur mit den Menschen, die du erwähnst, oder mit der ganzen Welt teilen möchtest. Mit Inhaltswarnungen kannst du Beiträge mit sensiblem oder triggerndem Inhalt ausblenden, bis du bereit bist, dich damit auseinanderzusetzen. Jede Gemeinschaft hat ihre eigenen Regeln und Moderator*innen, um die Sicherheit ihrer Mitglieder zu gewährleisten, sowie robuste Sperr- und Meldewerkzeuge um Missbrauch vorzubeugen.
|
||||
Mastodon wurde mit einem Schwerpunkt auf Privatsphäre und Sicherheit gebaut. Entscheide, ob du deine Beiträge mit deinen Followern, nur mit den Menschen, die du erwähnst, oder mit der ganzen Welt teilen möchtest. Mit Inhaltswarnungen kannst du Beiträge mit sensiblem oder 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.
|
||||
|
||||
Weitere Funktionen:
|
||||
|
||||
@@ -11,6 +11,6 @@ Weitere Funktionen:
|
||||
• Entdecken: Trending Hashtags und Accounts 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
|
||||
• 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 es so zu erhalten.
|
||||
@@ -1,6 +1,6 @@
|
||||
Mastodon es la red social descentralizada más grande de internet. En lugar de ser una sola web, es una red de millones de usuarios en comunidades independientes que pueden interactuar entre ellas de forma transparente. No importa qué es lo que hagas, podrás encontrar gente apasionada escribiendo sobre ello en Mastodon!
|
||||
|
||||
Únete a una comunidad y crea tu perfil. Encuentra y sigue a gente fascinante y lee sus publicaciones sin anuncios y de forma cronológica. Exprésate con emoticonos personalizados, imágenes, GIFs, vídeos y audio en publicaciónes de 500 caracteres. Responde a hilos e impulsa publicaciones de cualquiera para compartir contenido genial. Encuentra nuevas cuentas para seguir y los hashtags de actualidad para expandir tu red.
|
||||
Únete a una comunidad y crea tu perfil. Encuentra y sigue a gente fascinante y lee sus publicaciones sin anuncios y de forma cronológica. Exprésate con emoticonos personalizados, imágenes, GIFs, vídeos y audio en publicaciónes de 500 caracteres. Responde a hilos e rebloguea publicaciones de cualquiera para compartir contenido genial. Encuentra nuevas cuentas para seguir y los hashtags de actualidad para expandir tu red.
|
||||
|
||||
Mastodon está construída con un enfoque en la privacidad y la seguridad. Decide si tus publicaciones se comparten con tus seguidores, solo a la gente que menciones, o a todo el mundo. Las advertencias de contenido te permiten esconder publicaciones con contenido sensible o limitarlas de tu visión hasta que estés listo para interactuar con ellas. Cada comunidad tiene sus propias reglas y moderadores para mantener a salvo a sus miembros, además de herramientas robustas para bloquear y reportar contenido para prevenir el abuso.
|
||||
|
||||
@@ -9,7 +9,7 @@ Más características:
|
||||
• Modo oscuro: Lee las publicaciones en modo claro, oscuro o negro real
|
||||
• Encuestas: Pide opinión a tus seguidores y cuenta los votos
|
||||
• Explora: Hashtags y cuentas en tendencia a un solo toque
|
||||
• Notificaciones: Recibe notificaciones sobre nuevos seguidores, respuestas e impulsos
|
||||
• Notificaciones: Recibe notificaciones sobre nuevos seguidores, respuestas y reblogueos
|
||||
• Compartir: Publica directamente a Mastodon desde cualquier hoja de acción en cualquier aplicación
|
||||
• Preciosidad: Nuestra mascota es un elefante adorable, y verás que aparece de vez en cuando
|
||||
|
||||
|
||||
16
fastlane/metadata/android/fi-FI/full_description.txt
Normal file
16
fastlane/metadata/android/fi-FI/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon on internetin suurin hajautettu sosiaalinen verkosto. Yhden verkkopalvelun sijaan, se on miljoonien itsenäisissä yhteisöissä olevien käyttäjien verkosto, jotka voivat olla vuorovaikutuksessa toistensa kanssa saumattomasti. Riippumatta siitä, mistä olet kiinnostunut, voit tavata intohimoisia ihmisiä, jotka julkaisevat aiheesta Mastodonissa!
|
||||
|
||||
Liity yhteisöön ja luo itsellesi tili. Löydä ja seuraa kiehtovia ihmisiä ja lue heidän julkaisunsa ilman mainoksia, kronologisella aikajanalla. Ilmaise itseäsi mukautetuilla emojeilla, kuvilla, videoilla ja audiolla 500 merkin pituisissa julkaisuissa. Vastaa viestiketjuihin ja edelleen jaa julkaisuja keneltä tahansa, jakaaksesi hienoja juttuja. Löydä uusia tilejä seurattavaksi ja trendaavia hashtageja laajentaaksesi verkostoasi.
|
||||
|
||||
Mastodon on rakennettu keskittyen yksityisyyteen ja turvallisuuteen. Päätä, jaetaanko julkaisusi seuraajille, vain mainitsemillesi ihmisille vai koko maailmalle. Sisältövaroitusten avulla, voit piilottaa julkaisut, jotka sisältävät arkaluontoista tai laukaisevaa materiaalia, kunnes olet valmis käsittelemään niitä. Jokaisella yhteisöllä on omat ohjeistonsa ja valvojansa, jotka pitävät jäsenensä turvassa, ja tehokkaat esto- ja ilmiantotyökalut auttavat torjumaan väärinkäytöksiä.
|
||||
|
||||
Lisää ominaisuuksia:
|
||||
|
||||
• Tumma tila: Lue julkaisut vaaleassa, tummassa tai mustan tummassa tilassa
|
||||
• Kyselyt: Kysy seuraajilta heidän mielipidettään ja laske äänet
|
||||
• Tutustu: Trendaavat hashtagit ja tilit ovat vain napsautuksen päässä
|
||||
• Ilmoitukset: Saat ilmoituksen uusista seuraajista, vastauksista ja edelleen jaoista
|
||||
• Jakaminen: Julkaise suoraan Mastodoniin minkä tahansa sovelluksen jakovalikon kautta
|
||||
• Suloisuus: Maskottimme on ihastuttava mastodontti ja näet sen ajoittain
|
||||
|
||||
Mastodon on rekisteröity voittoa tavoittelematon organisaatio ja kehitystä tuetaan suoraan lahjoituksillasi. Ei mainontaa, kaupallistamista eikä riskipääomaa, ja aiomme pitää sen sellaisena.
|
||||
1
fastlane/metadata/android/fi-FI/short_description.txt
Normal file
1
fastlane/metadata/android/fi-FI/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Hajautettu sosiaalinen verkosto
|
||||
1
fastlane/metadata/android/fi-FI/title.txt
Normal file
1
fastlane/metadata/android/fi-FI/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
16
fastlane/metadata/android/hy-AM/full_description.txt
Normal file
16
fastlane/metadata/android/hy-AM/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/hy-AM/short_description.txt
Normal file
1
fastlane/metadata/android/hy-AM/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Decentralized social network
|
||||
1
fastlane/metadata/android/hy-AM/title.txt
Normal file
1
fastlane/metadata/android/hy-AM/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は、インターネット上で最大の分散型ソーシャルネットワークです。 Mastodonは単一のウェブサイトではなく、それぞれ独立したコミュニティに参加している何百万人ものユーザーによって構成されたネットワークなのです。ユーザーたちはその中で、誰もがお互いとシームレスにやり取りできます。 あなたの興味関心がどんな分野にあっても、きっとMastodonのどこかで同じ情熱を投稿している仲間がいますよ!
|
||||
|
||||
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
|
||||
まずはコミュニティに参加して、自分のプロフィールを作成しましょう。 そして素敵なユーザーを見つけて、フォローして、タイムラインで投稿を見てみましょう。タイムラインには広告なんてありませんし、順番も時系列順ですのでご安心を。 あるいは、500文字まで使える投稿で自分を表現してみましょう。カスタム絵文字や画像、GIF、動画、音声も使用できます。 スレッドに返事したり、他の誰かの面白い投稿をブーストして共有したりすることもできます。 新しいアカウントとホットなタグを見つけて、あなた自身のネットワークを広げていきましょう!
|
||||
|
||||
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
|
||||
Mastondonはプライバシーと安全性を重視しています。 自分の投稿をフォロワー限定公開にするのか、メンションした特定のユーザーにだけ共有するのか、全世界に大放流するのかは、すべてあなた次第。 また、入力中の投稿について「ちょっとセンシティブな内容だな」と思ったら、閲覧注意機能で内容を伏せることで、見たくない人に配慮した投稿が作成できます。 そして、各コミュニティにはそれぞれのガイドラインと管理者・モデレーターが存在し、コミュニティメンバーの安全を守っています。強力なブロック・通報機能も、不正利用の防止をお手伝いします。
|
||||
|
||||
More features:
|
||||
その他の機能:
|
||||
|
||||
• Dark Mode: Read posts in light, dark, or true black mode
|
||||
• Polls: Ask followers for their opinion and tally the votes
|
||||
• Explore: Trending hashtags and accounts are a tap away
|
||||
• Notifications: Get notified about new follows, replies, and reblogs
|
||||
• Sharing: Post directly to Mastodon from any share sheet in any app
|
||||
• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
|
||||
• ダークモード対応:ライトモードだけでなく、ダークモードや「真っ黒」モードで投稿を閲覧
|
||||
• 投票機能:フォロワーたちの意見を投票形式で集計
|
||||
• 探索:話題のハッシュタグやアカウントに1タップでアクセス
|
||||
• 通知設定:新しいフォローやリプライ、ブーストがあった時に通知
|
||||
• 共有:どのアプリからでも、「共有」メニューを通じてMastodonへ直接投稿
|
||||
• 癒し:Mastodonが誇る象のマスコット(かわいい)が、画面にお邪魔したり、しなかったり
|
||||
|
||||
Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
Mastodonは公認の非営利アプリです。開発は全てユーザーの寄付から成り立っています。 広告なし、アフィリエイトなし、第三者組織による出資なし。今でも、そしてこれからもそんなアプリであり続けるために、我々は日々努力し続けています。
|
||||
@@ -1 +1 @@
|
||||
Decentralized social network
|
||||
分散型ソーシャルネットワーク
|
||||
16
fastlane/metadata/android/kab-KAB/full_description.txt
Normal file
16
fastlane/metadata/android/kab-KAB/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon d azeṭṭa anmetti asrummsan meqqren deg internet. Ideg ara yili d asmel web asuf, d azeṭṭa n yimelyan n yiseqdacen deg temɣiwin tilelliyin i izemren ad myigwent gar-asent, s wudem afrawan. Akken ibɣu yili usentel i tḥemmleḍ, tzemreḍ ad temlileḍ imdanen i d-isuffuɣen ɣef usentel-nni ɣef Mastodon!
|
||||
|
||||
Rnu ɣer temɣiwent syen snulfu-d amaɣnu-inek. Af, rnu ḍfer imdanen yelhan. Teɣreḍ tisuffaɣ-nsen deg yizirig n wakud war adellel. Mmel iḥulfan-ik s yimujiten, tugniwin, GIFs, tividyutin d yimeslawen udmawanen deg tsuffaɣ n 500 yisekkilen. Ttekki deg usqerdec, talseḍ asuffeɣ n tsuffaɣ n yimdanen i beṭṭu n taktiwin igerrzen. Af imiḍanen ara tḍefreḍ akked hashtags mucaεen i wakken ad tesnerniḍ azeṭṭa-inek.
|
||||
|
||||
Mastodon yettwabna s tikci n wazal i tbaḍnit d tɣellist. Gzem-itt deg ṛṛay ma yella tisuffaɣ-inek·inem ad ttwabḍunt akked yineḍfaren-ik·im, akked yimdanen kan i d-tbedreḍ neɣ akked yimdanen meṛṛa. Ilɣa n ugbur ad ak·akem-yeǧǧ d teffreḍ tisuffaɣ ideg yella ugbur amḥalfu neɣ yir agbur alamma d asmi ara twejdeḍ ad tkecmeḍ ɣer-sen. Yal tamɣiwent ɣur-s ilugan-ines d yiseɣyaden-is i wakken ad teḍmentaɣellist n yiεeggalen-is, akked yifecka iǧehden i usewḥel d tummla n yineqqisen mgal yir aseqdec.
|
||||
|
||||
Ugar n temahilin:
|
||||
|
||||
• Askar aberkan: Γeṛ tisuffaɣ deg uskar aceεlal, aberkan neɣ aberkan aḥeqqani
|
||||
• Isenqaden: Ssuter ṛṛay n yineḍfaren syen smiḍen afran
|
||||
• Snirem: Hashtags d yimiḍanen mucaεen llan ɣef wafus
|
||||
• Ilɣa: Ṭṭef ilɣa ɣef yineḍfaren, tiririyin d wallus n usuffeɣ imaynuten
|
||||
• Beṭṭu: Azen srid ɣer Mastodon seg kra n tferkit n beṭṭu deg kra n usnas
|
||||
• Ucbiḥ: Lfal-nneɣ d ilu icebḥen aṭas, ad t-tetttwaliḍ yettban-d sya ɣer da
|
||||
|
||||
Mastodon d takebbanit ur nettnadi ara ɣef tedrimt, asnerni-ines yettili-d s tewsa-nni i as-tettmuddum. Ulac adellel, ur njemmeε tadrimt, ur nesεi win aɣ-d-yettakken tadrimt. Akka i nettxemmim ad nkemmel abrid-nneɣ.
|
||||
1
fastlane/metadata/android/kab-KAB/short_description.txt
Normal file
1
fastlane/metadata/android/kab-KAB/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Azeṭṭa anmetti asrummsan
|
||||
1
fastlane/metadata/android/kab-KAB/title.txt
Normal file
1
fastlane/metadata/android/kab-KAB/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
|
||||
분산화된 소셜 네트워크
|
||||
@@ -1 +1 @@
|
||||
Mastodon
|
||||
마스토돈
|
||||
@@ -1,16 +1,16 @@
|
||||
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. Find new accounts to follow and trending hashtags to expand your network.
|
||||
Dołącz do społeczności i utwórz swój profil. Poznaj i obserwuj fascynujących ludzi i czytaj ich wpisy w chronologicznym osi czasu. Wyrażaj siebie za pomocą niestandardowych emoji, obrazów, GIFów, filmów i audio w 500-znakowych wpisach. Odpowiadaj na wątki i podawaj dalej posty od każdego, aby dzielić się wspaniałymi rzeczami. Odnajduj nowe konta do śledzenia i zyskujące popularność hashtagi, by poszerzać swoją sieć.
|
||||
|
||||
Mastodon został zaprojektowany z myślą o prywatności i bezpieczeństwie. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Ostrzeżenia dotyczące zawartości pozwalają ukrywać posty zawierające wrażliwe lub wyzywające materiały, dopóki nie będziesz gotów się z nimi pogodzić. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
|
||||
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.
|
||||
|
||||
Więcej funkcji:
|
||||
|
||||
• Tryb ciemny: Czytaj wpisy w jasnym, ciemnym lub czarnym trybie
|
||||
• Ankiety: Poproś obserwujących o ich opinię i poznaj ich głosy
|
||||
• Explore: Trending hashtags and accounts are a tap away
|
||||
• Odkrywaj: Najpopularniejsze hashtagi i konta są dostępne za jednym dotknięciem
|
||||
• Powiadomienia: Otrzymuj powiadomienia o nowych obserwacjach, odpowiedziach i udostępnieniach
|
||||
• 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
|
||||
• 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 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 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.
|
||||
@@ -1,8 +1,8 @@
|
||||
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!
|
||||
O Mastodon é a maior rede social descentralizada da Internet. Em vez de ser um único site, é uma rede de milhões de utilizadores em comunidades independentes que podem facilmente interagir uns com os outros. Independemente dos teus gostos, consegues encontrar pessoas que os partilhem 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.
|
||||
Junta-te a uma comunidade e cria o teu perfil. Encontra, segue gente fascinante e lê as suas publicações numa cronologia sem anúncios. Expressa-te com emojis personalizados imagens, GIFs, vídeos e áudio em publicações com até 500 caracteres. Responde a tópicos e promove publicações de qualquer pessoa para partilhares ótimas coisas. Encontra novas contas e tendências a seguir para expandires a tua 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 é construído com foco na privacidade e segurança. Decide se as tuas publicações são partilhadas com os teus seguidores, apenas com as pessoas mencionadas, ou com o mundo inteiro. Avisos de conteúdo permitem-te esconder publicações que contenham material sensível ou provocatório até estares pronto(a) para o veres. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
|
||||
|
||||
More features:
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
Decentralized social network
|
||||
Rede social descentralizada
|
||||
@@ -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 — это крупнейшая распределённая социальная сеть в интернете. Вместо одного сайта, это сеть из независимых сообществ с миллионами пользователей, которые могут бесшовно взаимодействовать друг с другом. Вне зависимости от того, чем вы увлекаетесь, вы всегда найдёте себе единомышленников в Mastodon!
|
||||
|
||||
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
|
||||
Вступите в сообщество по интересу и создайте свой профиль. Ищите и подписывайтесь на увлекательных пользователей, читайте их посты без рекламы в хронологической ленте. Выражайте себя в 500-символьных постах, дополняя их пользовательскими эмодзи, изображениями, гифками, видео и аудио. Участвуйте в обсуждениях и продвигайте отличные посты от других людей. Расширяйте свой кругозор, находя новых интересных людей и следя за актуальными хэштегами.
|
||||
|
||||
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
|
||||
Mastodon создан с акцентом на конфиденциальность и безопасность. Решайте с кем вы хотите поделиться своими постами: своими подписчиками, только упомянутыми людьми или же вообще со всем миром. Предупреждения о содержимом позволят вам скрыть посты содержащие материалы деликатного или шокирующего характера. В каждом сообществе свои правила и модераторы, следящие за порядком, а надёжные инструменты блокировки и система жалоб помогают предотвращать злоупотребление.
|
||||
|
||||
More features:
|
||||
Ещё больше возможностей:
|
||||
|
||||
• Dark Mode: Read posts in light, dark, or true black mode
|
||||
• Polls: Ask followers for their opinion and tally the votes
|
||||
• Explore: Trending hashtags and accounts are a tap away
|
||||
• Notifications: Get notified about new follows, replies, and reblogs
|
||||
• Sharing: Post directly to Mastodon from any share sheet in any app
|
||||
• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
|
||||
• Темы на любой вкус: читайте посты в светлом, тёмном или OLED режимах
|
||||
• Спрашивайте мнение подписчиков и подсчитывайте их голоса с опросами
|
||||
• Найдите актуальные хэштеги, интересные посты и профили во вкладке «Обзор»
|
||||
• Будьте в курсе происходящего с уведомлениями о новых подписчиках, ответах и продвижениях
|
||||
• Делитесь в Mastodon содержимым из любого приложения
|
||||
• Умиляйтесь с нашим талисманом, восхитительным слонёнком, которого можно встретить и тут и там
|
||||
|
||||
Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
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 ä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!
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 ä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.
|
||||
|
||||
More features:
|
||||
Fler funktioner:
|
||||
|
||||
• 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
|
||||
• 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
|
||||
• 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å
|
||||
|
||||
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 är en registrerad ideell förening och utvecklingen stöds direkt av dina donationer. Det finns ingen reklam, ingen monetarisering, och inget riskkapital, och vi planerar att behålla det på det sättet.
|
||||
@@ -1 +1 @@
|
||||
Decentralized social network
|
||||
Decentraliserat socialt nätverk
|
||||
16
fastlane/metadata/android/th-TH/full_description.txt
Normal file
16
fastlane/metadata/android/th-TH/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon เป็นเครือข่ายสังคมแบบกระจายศูนย์ที่ใหญ่ที่สุดบนอินเทอร์เน็ต ซึ่งไม่ได้เป็นเว็บไซต์เดียว แต่เป็นเครือข่ายของผู้ใช้หลายล้านคนในชุมชนอิสระที่ทุกคนสามารถโต้ตอบซึ่งกันและกันได้แบบไร้รอยต่อ ไม่ว่าคุณจะชอบอะไร คุณก็พบคนที่ชื่นชอบเหมือนกันโพสต์เกี่ยวกับสิ่งที่คุณชอบได้บน Mastodon!
|
||||
|
||||
เข้าร่วมชุมชนและสร้างโปรไฟล์ ค้นหาและติดตามผู้คนที่น่าสนใจและอ่านโพสต์ของเขาในไทม์ไลน์ที่ไร้โฆษณาและเรียงตามลำดับเวลาล้วน ๆ แสดงความรู้สึกของตัวคุณเองด้วยอีโมจิที่กำหนดเอง รูปภาพ GIF วิดีโอ และเสียงในโพสต์ 500 ตัวอักษร ตอบกลับและดันโพสต์จากคนอื่น ๆ เพื่อแชร์สิ่งดี ๆ และค้นหาบัญชีใหม่ ๆ ที่จะติดตามและแฮชแท็กที่เป็นที่นิยมเพื่อขยายเครือข่ายของคุณ
|
||||
|
||||
Mastodon สร้างขึ้นโดยเน้นความเป็นส่วนตัวและความปลอดภัยเป็นสำคัญ คุณสามารถตัดสินใจได้ว่าโพสต์ของคุณจะถูกแชร์กับผู้ติดตามของคุณ คนที่คุณกล่าวถึง หรือคนทั้งโลกก็ได้ ฟังก์ชั่นคำเตือนเนื้อหาช่วยให้คุณซ่อนโพสต์ที่มีเนื้อหาที่ละเอียดอ่อนจนกว่าคุณจะพร้อมเห็นเนื้อหานั้น แต่ละชุมชนจะมีหลักเกณฑ์และผู้ควบคุมเป็นของตนเองเพื่อรักษาความปลอดภัยของสมาชิก รวมถึงเครื่องมือการปิดกั้นและรายงานที่มีประสิทธิภาพเพื่อช่วยป้องกันการกระทำผิด
|
||||
|
||||
คุณสมบัติอื่น ๆ:
|
||||
|
||||
• โหมดมืด: อ่านโพสต์ในโหมดสว่าง มืด หรือโหมดมืดดำสนิท
|
||||
• การสำรวจความคิดเห็น: สำรวจความคิดเห็นของผู้ติดตามและนับจำนวนการลงคะแนน
|
||||
• สำรวจ: แตะปุ่มเดียวเพื่อดูแฮชแท็กและบัญชีที่เป็นที่นิยม
|
||||
• การแจ้งเตือน: รับการแจ้งเตือนเกี่ยวกับผู้ติดตามใหม่ การตอบกลับ และการดันโพสต์
|
||||
• การแชร์: โพสต์ลง Mastodon ได้โดยตรงจากแอปอื่น ๆ ที่อยู่ในเครื่อง
|
||||
• ความน่ารัก: มาสคอตของเราเป็นช้างน่ารัก และคุณจะเห็นมันโผล่ออกมาเป็นระยะ ๆ
|
||||
|
||||
Mastodon เป็นองค์กรไม่แสวงหาผลกำไรที่จดทะเบียนแล้ว และการพัฒนาได้รับการสนับสนุนจากเงินบริจาคของคุณโดยตรง ดังนั้นจึงไม่มีโฆษณา ไม่มีการทำกำไร และไม่มีการร่วมลงทุน และเรามีแผนจะทำให้เป็นอย่างนี้ต่อไป
|
||||
1
fastlane/metadata/android/th-TH/short_description.txt
Normal file
1
fastlane/metadata/android/th-TH/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
เครือข่ายสังคมแบบกระจายศูนย์
|
||||
1
fastlane/metadata/android/th-TH/title.txt
Normal file
1
fastlane/metadata/android/th-TH/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
@@ -1,8 +1,8 @@
|
||||
Mastodon là mạng xã hội liên hợp lớn nhất trên internet. Thay vì một trang web duy nhất, nó là một mạng lưới hàng triệu người dùng trong các cộng đồng độc lập, tất cả đều có thể tương tác với nhau một cách liền mạch. Bất kể bạn thích gì, bạn đều có thể gặp gỡ những người đăng tút về nó trên Mastodon!
|
||||
Mastodon là mạng xã hội liên hợp lớn nhất trên internet. Thay vì một trang web duy nhất, nó là một mạng lưới hàng triệu người dùng trong các máy chủ độc lập, tất cả đều có thể tương tác với nhau một cách liền mạch. Bất kể bạn thích gì, bạn đều có thể gặp gỡ những người đăng tút về nó trên Mastodon!
|
||||
|
||||
Tham gia một cộng đồng và tạo trang hồ sơ của bạn. Tìm, theo dõi những người thú vị và đọc tút của họ theo trình tự thời gian, không có quảng cáo. Thể hiện bản thân bằng emoji, hình ảnh, GIF, video và âm thanh trong tút tối đa 500 ký tự. Trả lời tút và đăng lại tút từ bất kỳ ai để chia sẻ những điều tuyệt vời. Tìm những người dùng mới để theo dõi và các hashtag xu hướng để mở rộng mạng lưới của bạn.
|
||||
Tham gia một máy chủ và tạo trang hồ sơ của bạn. Tìm, theo dõi những người thú vị và đọc tút của họ theo trình tự thời gian, không có quảng cáo. Thể hiện bản thân bằng emoji, hình ảnh, GIF, video và âm thanh trong tút tối đa 500 ký tự. Trả lời tút và đăng lại tút từ bất kỳ ai để chia sẻ những điều tuyệt vời. Tìm những người dùng mới để theo dõi và các hashtag xu hướng để mở rộng mạng lưới của bạn.
|
||||
|
||||
Mastodon được xây dựng tập trung vào sự riêng tư và an toàn. Quyết định xem tút của bạn được chia sẻ với những người theo dõi, chỉ những người bạn nhắc đến hay cả thế giới. Nội dung ẩn cho phép bạn ẩn các tút chứa nội dung nhạy cảm hoặc chơi chữ cho đến khi bạn sẵn sàng tương tác với chúng. Mỗi cộng đồng có các nguyên tắc riêng và kiểm duyệt viên riêng để giữ an toàn cho các thành viên, song song với các công cụ chặn và báo cáo mạnh mẽ giúp ngăn chặn hành vi bậy.
|
||||
Mastodon được xây dựng tập trung vào sự riêng tư và an toàn. Quyết định xem tút của bạn được chia sẻ với những người theo dõi, chỉ những người bạn nhắc đến hay cả thế giới. Nội dung ẩn cho phép bạn ẩn các tút chứa nội dung nhạy cảm hoặc chơi chữ cho đến khi bạn sẵn sàng tương tác với chúng. Mỗi máy chủ có các nguyên tắc riêng và kiểm duyệt viên riêng để giữ an toàn cho các thành viên, song song với các công cụ chặn và báo cáo mạnh mẽ giúp ngăn chặn hành vi bậy.
|
||||
|
||||
Tính năng khác:
|
||||
|
||||
|
||||
@@ -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 是網際網路上最大的去中心化社交網路。 它是一個由能無縫互動的獨立社群中,數百萬使用者組成的網路,而非單一網站。 無論您對什麼事情感興趣,您都能在 Mastodon 上遇到充滿熱情的人們討論該話題。
|
||||
|
||||
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
|
||||
加入社群並建立您的個人檔案。 尋找並追蹤迷人的夥伴,並在無廣告、按時間順序排列的時間軸上閱讀他們的貼文。 在 500 個字元的貼文中使用自訂表情符號、GIF、視訊與音訊來表達您自己。 回覆任何人的話題與轉發貼文以分享精彩內容。 尋找要追蹤的新帳號與熱門主題標籤來拓展您的網路。
|
||||
|
||||
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
|
||||
Mastodon 以隱私與安全為要。 決定您的貼文要與您的追蹤者分享、只與您提及的人們分享,又或是與全世界分享。 內容警告可讓您隱藏包含敏感或可能觸發強烈情緒反應的貼文,直到您準備好與它們進行互動。 每個社群都有它們自己的指導方針與管理原來確保其成員安全,強大的封鎖與回報工具有助於防止濫用。
|
||||
|
||||
More features:
|
||||
更多功能:
|
||||
|
||||
• Dark Mode: Read posts in light, dark, or true black mode
|
||||
• Polls: Ask followers for their opinion and tally the votes
|
||||
• Explore: Trending hashtags and accounts are a tap away
|
||||
• Notifications: Get notified about new follows, replies, and reblogs
|
||||
• Sharing: Post directly to Mastodon from any share sheet in any app
|
||||
• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
|
||||
• 深色模式:以淺色、深色或純黑色模式閱讀貼文
|
||||
• 投票:詢問追蹤的意見並計票
|
||||
• 探索:僅需輕點一下,即可看到熱門主題標籤與帳號
|
||||
• 通知:取得關於新追蹤、回覆與轉發的通知
|
||||
• 分享:從任何應用程式中的分享表中直接發表貼文到 Mastodon 中
|
||||
• 可愛:我們的吉祥物是一隻可愛的大象,您會不時看到牠出現
|
||||
|
||||
Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way.
|
||||
Mastodon 是一家註冊的非營利組織,您的捐款會直接支援開發工作。 沒有廣告、沒有貨幣化、沒有風險投資,我們計畫維持這種狀態。
|
||||
@@ -1 +1 @@
|
||||
Decentralized social network
|
||||
去中心化社群網路
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId "org.joinmastodon.android"
|
||||
minSdk 23
|
||||
targetSdk 31
|
||||
versionCode 32
|
||||
versionName "1.0.2"
|
||||
versionCode 39
|
||||
versionName "1.1.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ dependencies {
|
||||
implementation 'me.grishka.litex:dynamicanimation:1.1.0-alpha03'
|
||||
implementation 'me.grishka.litex:viewpager:1.0.0'
|
||||
implementation 'me.grishka.litex:viewpager2:1.0.0'
|
||||
implementation 'me.grishka.appkit:appkit:1.2.2'
|
||||
implementation 'me.grishka.appkit:appkit:1.2.6'
|
||||
implementation 'com.google.code.gson:gson:2.8.9'
|
||||
implementation 'org.jsoup:jsoup:1.14.3'
|
||||
implementation 'com.squareup:otto:1.3.8'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 46 KiB |
@@ -9,6 +9,7 @@ import android.util.Log;
|
||||
import org.joinmastodon.android.api.ObjectValidationException;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.ComposeFragment;
|
||||
import org.joinmastodon.android.fragments.HomeFragment;
|
||||
import org.joinmastodon.android.fragments.ProfileFragment;
|
||||
import org.joinmastodon.android.fragments.SplashFragment;
|
||||
@@ -56,6 +57,8 @@ public class MainActivity extends FragmentStackActivity{
|
||||
if(intent.getBooleanExtra("fromNotification", false) && intent.hasExtra("notification")){
|
||||
Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification"));
|
||||
showFragmentForNotification(notification, session.getID());
|
||||
}else if(intent.getBooleanExtra("compose", false)){
|
||||
showCompose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +94,8 @@ public class MainActivity extends FragmentStackActivity{
|
||||
fragment.setArguments(args);
|
||||
showFragmentClearingBackStack(fragment);
|
||||
}
|
||||
}else if(intent.getBooleanExtra("compose", false)){
|
||||
showCompose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,4 +120,15 @@ public class MainActivity extends FragmentStackActivity{
|
||||
fragment.setArguments(args);
|
||||
showFragment(fragment);
|
||||
}
|
||||
|
||||
private void showCompose(){
|
||||
AccountSession session=AccountSessionManager.getInstance().getLastActiveAccount();
|
||||
if(session==null || !session.activated)
|
||||
return;
|
||||
ComposeFragment compose=new ComposeFragment();
|
||||
Bundle composeArgs=new Bundle();
|
||||
composeArgs.putString("account", session.getID());
|
||||
compose.setArguments(composeArgs);
|
||||
showFragment(compose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import android.util.Log;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIController;
|
||||
import org.joinmastodon.android.api.requests.notifications.GetNotificationByID;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.PushNotification;
|
||||
@@ -52,10 +53,23 @@ public class PushNotificationReceiver extends BroadcastReceiver{
|
||||
String k=intent.getStringExtra("k");
|
||||
String p=intent.getStringExtra("p");
|
||||
String s=intent.getStringExtra("s");
|
||||
String accountID=intent.getStringExtra("x");
|
||||
if(!TextUtils.isEmpty(accountID) && !TextUtils.isEmpty(k) && !TextUtils.isEmpty(p) && !TextUtils.isEmpty(s)){
|
||||
String pushAccountID=intent.getStringExtra("x");
|
||||
if(!TextUtils.isEmpty(pushAccountID) && !TextUtils.isEmpty(k) && !TextUtils.isEmpty(p) && !TextUtils.isEmpty(s)){
|
||||
MastodonAPIController.runInBackground(()->{
|
||||
try{
|
||||
List<AccountSession> accounts=AccountSessionManager.getInstance().getLoggedInAccounts();
|
||||
AccountSession account=null;
|
||||
for(AccountSession acc:accounts){
|
||||
if(pushAccountID.equals(acc.pushAccountID)){
|
||||
account=acc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(account==null){
|
||||
Log.w(TAG, "onReceive: account for id '"+pushAccountID+"' not found");
|
||||
return;
|
||||
}
|
||||
String accountID=account.getID();
|
||||
PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s);
|
||||
new GetNotificationByID(pn.notificationId+"")
|
||||
.setCallback(new Callback<>(){
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.joinmastodon.android.api;
|
||||
|
||||
import android.graphics.Rect;
|
||||
import android.net.Uri;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class AvatarResizedImageRequestBody extends ResizedImageRequestBody{
|
||||
public AvatarResizedImageRequestBody(Uri uri, ProgressListener progressListener) throws IOException{
|
||||
super(uri, 0, progressListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int[] getTargetSize(int srcWidth, int srcHeight){
|
||||
float factor=400f/Math.min(srcWidth, srcHeight);
|
||||
return new int[]{Math.round(srcWidth*factor), Math.round(srcHeight*factor)};
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean needResize(int srcWidth, int srcHeight){
|
||||
return srcHeight>400 || srcWidth!=srcHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean needCrop(int srcWidth, int srcHeight){
|
||||
return srcWidth!=srcHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Rect getCropBounds(int srcWidth, int srcHeight){
|
||||
Rect rect=new Rect();
|
||||
if(srcWidth>srcHeight){
|
||||
rect.set(srcWidth/2-srcHeight/2, 0, srcWidth/2-srcHeight/2+srcHeight, srcHeight);
|
||||
}else{
|
||||
rect.set(0, srcHeight/2-srcWidth/2, srcWidth, srcHeight/2-srcWidth/2+srcWidth);
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,13 @@ import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
|
||||
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.model.CacheablePaginatedResponse;
|
||||
import org.joinmastodon.android.model.Filter;
|
||||
import org.joinmastodon.android.model.Notification;
|
||||
import org.joinmastodon.android.model.PaginatedResponse;
|
||||
import org.joinmastodon.android.model.SearchResult;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.utils.StatusFilterPredicate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
@@ -41,6 +43,8 @@ public class CacheController{
|
||||
private DatabaseHelper db;
|
||||
private final Runnable databaseCloseRunnable=this::closeDatabase;
|
||||
|
||||
private static final int POST_FLAG_GAP_AFTER=1;
|
||||
|
||||
static{
|
||||
databaseThread.start();
|
||||
}
|
||||
@@ -49,14 +53,14 @@ public class CacheController{
|
||||
this.accountID=accountID;
|
||||
}
|
||||
|
||||
public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback<PaginatedResponse<List<Status>>> callback){
|
||||
public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback<CacheablePaginatedResponse<List<Status>>> callback){
|
||||
cancelDelayedClose();
|
||||
databaseThread.postRunnable(()->{
|
||||
try{
|
||||
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
|
||||
if(!forceReload){
|
||||
SQLiteDatabase db=getOrOpenDatabase();
|
||||
try(Cursor cursor=db.query("home_timeline", new String[]{"json"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){
|
||||
try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){
|
||||
if(cursor.getCount()==count){
|
||||
ArrayList<Status> result=new ArrayList<>();
|
||||
cursor.moveToFirst();
|
||||
@@ -65,6 +69,8 @@ public class CacheController{
|
||||
do{
|
||||
Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class);
|
||||
status.postprocess();
|
||||
int flags=cursor.getInt(1);
|
||||
status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0);
|
||||
newMaxID=status.id;
|
||||
for(Filter filter:filters){
|
||||
if(filter.matches(status.getContentStatus().content))
|
||||
@@ -73,25 +79,18 @@ public class CacheController{
|
||||
result.add(status);
|
||||
}while(cursor.moveToNext());
|
||||
String _newMaxID=newMaxID;
|
||||
uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID)));
|
||||
uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true)));
|
||||
return;
|
||||
}
|
||||
}catch(IOException x){
|
||||
Log.w(TAG, "getHomeTimeline: corrupted status object in database", x);
|
||||
}
|
||||
}
|
||||
new GetHomeTimeline(maxID, null, count)
|
||||
new GetHomeTimeline(maxID, null, count, null)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
callback.onSuccess(new PaginatedResponse<>(result.stream().filter(post->{
|
||||
for(Filter filter:filters){
|
||||
if(filter.matches(post.getContentStatus().content)){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id));
|
||||
callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false));
|
||||
putHomeTimeline(result, maxID==null);
|
||||
}
|
||||
|
||||
@@ -110,14 +109,18 @@ public class CacheController{
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private void putHomeTimeline(List<Status> posts, boolean clear){
|
||||
public void putHomeTimeline(List<Status> posts, boolean clear){
|
||||
runOnDbThread((db)->{
|
||||
if(clear)
|
||||
db.delete("home_timeline", null, null);
|
||||
ContentValues values=new ContentValues(2);
|
||||
ContentValues values=new ContentValues(3);
|
||||
for(Status s:posts){
|
||||
values.put("id", s.id);
|
||||
values.put("json", MastodonAPIController.gson.toJson(s));
|
||||
int flags=0;
|
||||
if(s.hasGapAfter)
|
||||
flags|=POST_FLAG_GAP_AFTER;
|
||||
values.put("flags", flags);
|
||||
db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE);
|
||||
}
|
||||
});
|
||||
@@ -230,6 +233,12 @@ public class CacheController{
|
||||
});
|
||||
}
|
||||
|
||||
public void deleteStatus(String id){
|
||||
runOnDbThread((db)->{
|
||||
db.delete("home_timeline", "`id`=?", new String[]{id});
|
||||
});
|
||||
}
|
||||
|
||||
public void clearRecentSearches(){
|
||||
runOnDbThread((db)->db.delete("recent_searches", null, null));
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package org.joinmastodon.android.api;
|
||||
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.gson.FieldNamingPolicy;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonIOException;
|
||||
import com.google.gson.JsonObject;
|
||||
@@ -16,11 +12,9 @@ import com.google.gson.JsonParser;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
|
||||
import org.joinmastodon.android.BuildConfig;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter;
|
||||
import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.model.BaseModel;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
@@ -144,7 +138,7 @@ public class MastodonAPIController{
|
||||
}
|
||||
|
||||
try{
|
||||
req.validateAndPostprocessResponse(respObj);
|
||||
req.validateAndPostprocessResponse(respObj, response);
|
||||
}catch(IOException x){
|
||||
if(BuildConfig.DEBUG)
|
||||
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x);
|
||||
|
||||
@@ -28,6 +28,7 @@ import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import okhttp3.Call;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
|
||||
private static final String TAG="MastodonAPIRequest";
|
||||
@@ -75,9 +76,14 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
|
||||
}
|
||||
|
||||
public MastodonAPIRequest<T> exec(String accountID){
|
||||
account=AccountSessionManager.getInstance().getAccount(accountID);
|
||||
domain=account.domain;
|
||||
account.getApiController().submitRequest(this);
|
||||
try{
|
||||
account=AccountSessionManager.getInstance().getAccount(accountID);
|
||||
domain=account.domain;
|
||||
account.getApiController().submitRequest(this);
|
||||
}catch(Exception x){
|
||||
Log.e(TAG, "exec: this shouldn't happen, but it still did", x);
|
||||
invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -153,7 +159,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
public void validateAndPostprocessResponse(T respObj) throws IOException{
|
||||
public void validateAndPostprocessResponse(T respObj, Response httpResponse) throws IOException{
|
||||
if(respObj instanceof BaseModel){
|
||||
((BaseModel) respObj).postprocess();
|
||||
}else if(respObj instanceof List){
|
||||
|
||||
@@ -26,6 +26,8 @@ public class MastodonErrorResponse extends ErrorResponse{
|
||||
|
||||
@Override
|
||||
public void showToast(Context context){
|
||||
if(context==null)
|
||||
return;
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,16 +121,12 @@ public class PushSubscriptionManager{
|
||||
return !TextUtils.isEmpty(deviceToken);
|
||||
}
|
||||
|
||||
public void registerAccountForPush(){
|
||||
registerAccountForPush(null);
|
||||
}
|
||||
|
||||
public void registerAccountForPush(PushSubscription subscription){
|
||||
if(TextUtils.isEmpty(deviceToken))
|
||||
throw new IllegalStateException("No device push token available");
|
||||
MastodonAPIController.runInBackground(()->{
|
||||
Log.d(TAG, "registerAccountForPush: started for "+accountID);
|
||||
String encodedPublicKey, encodedAuthKey;
|
||||
String encodedPublicKey, encodedAuthKey, pushAccountID;
|
||||
try{
|
||||
KeyPairGenerator generator=KeyPairGenerator.getInstance("EC");
|
||||
ECGenParameterSpec spec=new ECGenParameterSpec(EC_CURVE_NAME);
|
||||
@@ -140,11 +136,17 @@ public class PushSubscriptionManager{
|
||||
privateKey=keyPair.getPrivate();
|
||||
encodedPublicKey=Base64.encodeToString(serializeRawPublicKey(publicKey), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
authKey=new byte[16];
|
||||
new SecureRandom().nextBytes(authKey);
|
||||
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
|
||||
SecureRandom secureRandom=new SecureRandom();
|
||||
secureRandom.nextBytes(authKey);
|
||||
byte[] randomAccountID=new byte[16];
|
||||
secureRandom.nextBytes(randomAccountID);
|
||||
AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID);
|
||||
if(session==null)
|
||||
return;
|
||||
session.pushPrivateKey=Base64.encodeToString(privateKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
session.pushPublicKey=Base64.encodeToString(publicKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
session.pushAuthKey=encodedAuthKey=Base64.encodeToString(authKey, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
session.pushAccountID=pushAccountID=Base64.encodeToString(randomAccountID, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
AccountSessionManager.getInstance().writeAccountsFile();
|
||||
}catch(NoSuchAlgorithmException|InvalidAlgorithmParameterException e){
|
||||
Log.e(TAG, "registerAccountForPush: error generating encryption key", e);
|
||||
@@ -155,14 +157,16 @@ public class PushSubscriptionManager{
|
||||
encodedAuthKey,
|
||||
subscription==null ? PushSubscription.Alerts.ofAll() : subscription.alerts,
|
||||
subscription==null ? PushSubscription.Policy.ALL : subscription.policy,
|
||||
accountID)
|
||||
pushAccountID)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(PushSubscription result){
|
||||
MastodonAPIController.runInBackground(()->{
|
||||
serverKey=deserializeRawPublicKey(Base64.decode(result.serverKey, Base64.URL_SAFE));
|
||||
|
||||
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
|
||||
AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID);
|
||||
if(session==null)
|
||||
return;
|
||||
session.pushSubscription=result;
|
||||
AccountSessionManager.getInstance().writeAccountsFile();
|
||||
Log.d(TAG, "Successfully registered "+accountID+" for push notifications");
|
||||
@@ -183,7 +187,9 @@ public class PushSubscriptionManager{
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(PushSubscription result){
|
||||
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
|
||||
AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID);
|
||||
if(session==null)
|
||||
return;
|
||||
if(result.policy!=subscription.policy)
|
||||
result.policy=subscription.policy;
|
||||
session.pushSubscription=result;
|
||||
@@ -196,7 +202,9 @@ public class PushSubscriptionManager{
|
||||
if(((MastodonErrorResponse)error).httpStatus==404){ // Not registered for push, register now
|
||||
registerAccountForPush(subscription);
|
||||
}else{
|
||||
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
|
||||
AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID);
|
||||
if(session==null)
|
||||
return;
|
||||
session.needUpdatePushSettings=true;
|
||||
session.pushSubscription=subscription;
|
||||
AccountSessionManager.getInstance().writeAccountsFile();
|
||||
@@ -359,7 +367,7 @@ public class PushSubscriptionManager{
|
||||
private static void registerAllAccountsForPush(boolean forceReRegister){
|
||||
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
|
||||
if(session.pushSubscription==null || forceReRegister)
|
||||
session.getPushSubscriptionManager().registerAccountForPush();
|
||||
session.getPushSubscriptionManager().registerAccountForPush(session.pushSubscription);
|
||||
else if(session.needUpdatePushSettings)
|
||||
session.getPushSubscriptionManager().updatePushSettings(session.pushSubscription);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.os.Build;
|
||||
import android.provider.OpenableColumns;
|
||||
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
@@ -30,62 +31,105 @@ public class ResizedImageRequestBody extends CountingRequestBody{
|
||||
private File tempFile;
|
||||
private Uri uri;
|
||||
private String contentType;
|
||||
private int maxSize;
|
||||
|
||||
public ResizedImageRequestBody(Uri uri, int maxSize, ProgressListener progressListener) throws IOException{
|
||||
super(progressListener);
|
||||
this.uri=uri;
|
||||
contentType=MastodonApp.context.getContentResolver().getType(uri);
|
||||
this.maxSize=maxSize;
|
||||
BitmapFactory.Options opts=new BitmapFactory.Options();
|
||||
opts.inJustDecodeBounds=true;
|
||||
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
|
||||
BitmapFactory.decodeStream(in, null, opts);
|
||||
if("file".equals(uri.getScheme())){
|
||||
BitmapFactory.decodeFile(uri.getPath(), opts);
|
||||
contentType=UiUtils.getFileMediaType(new File(uri.getPath())).type();
|
||||
}else{
|
||||
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
|
||||
BitmapFactory.decodeStream(in, null, opts);
|
||||
}
|
||||
contentType=MastodonApp.context.getContentResolver().getType(uri);
|
||||
}
|
||||
if(opts.outWidth*opts.outHeight>maxSize){
|
||||
if(needResize(opts.outWidth, opts.outHeight) || needCrop(opts.outWidth, opts.outHeight)){
|
||||
Bitmap bitmap;
|
||||
if(Build.VERSION.SDK_INT>=29){
|
||||
bitmap=ImageDecoder.decodeBitmap(ImageDecoder.createSource(MastodonApp.context.getContentResolver(), uri), (decoder, info, source)->{
|
||||
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)info.getSize().getWidth()/info.getSize().getHeight())));
|
||||
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)info.getSize().getHeight()/info.getSize().getWidth())));
|
||||
if(Build.VERSION.SDK_INT>=28){
|
||||
ImageDecoder.Source source;
|
||||
if("file".equals(uri.getScheme())){
|
||||
source=ImageDecoder.createSource(new File(uri.getPath()));
|
||||
}else{
|
||||
source=ImageDecoder.createSource(MastodonApp.context.getContentResolver(), uri);
|
||||
}
|
||||
bitmap=ImageDecoder.decodeBitmap(source, (decoder, info, _source)->{
|
||||
int[] size=getTargetSize(info.getSize().getWidth(), info.getSize().getHeight());
|
||||
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE);
|
||||
decoder.setTargetSize(targetWidth, targetHeight);
|
||||
decoder.setTargetSize(size[0], size[1]);
|
||||
// Breaks images in mysterious ways
|
||||
// if(needCrop(size[0], size[1]))
|
||||
// decoder.setCrop(getCropBounds(size[0], size[1]));
|
||||
});
|
||||
if(needCrop(bitmap.getWidth(), bitmap.getHeight())){
|
||||
Rect crop=getCropBounds(bitmap.getWidth(), bitmap.getHeight());
|
||||
bitmap=Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height());
|
||||
}
|
||||
}else{
|
||||
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)opts.outWidth/opts.outHeight)));
|
||||
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)opts.outHeight/opts.outWidth)));
|
||||
int[] size=getTargetSize(opts.outWidth, opts.outHeight);
|
||||
int targetWidth=size[0];
|
||||
int targetHeight=size[1];
|
||||
float factor=opts.outWidth/(float)targetWidth;
|
||||
opts=new BitmapFactory.Options();
|
||||
opts.inSampleSize=(int)factor;
|
||||
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
|
||||
bitmap=BitmapFactory.decodeStream(in, null, opts);
|
||||
if("file".equals(uri.getScheme())){
|
||||
bitmap=BitmapFactory.decodeFile(uri.getPath(), opts);
|
||||
}else{
|
||||
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
|
||||
bitmap=BitmapFactory.decodeStream(in, null, opts);
|
||||
}
|
||||
}
|
||||
if(factor%1f!=0f){
|
||||
Bitmap scaled=Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888);
|
||||
new Canvas(scaled).drawBitmap(bitmap, null, new Rect(0, 0, targetWidth, targetHeight), new Paint(Paint.FILTER_BITMAP_FLAG));
|
||||
boolean needCrop=needCrop(targetWidth, targetHeight);
|
||||
if(factor%1f!=0f || needCrop){
|
||||
Rect srcBounds=null;
|
||||
Rect dstBounds;
|
||||
if(needCrop){
|
||||
Rect crop=getCropBounds(targetWidth, targetHeight);
|
||||
dstBounds=new Rect(0, 0, crop.width(), crop.height());
|
||||
srcBounds=new Rect(
|
||||
Math.round(crop.left/(float)targetWidth*bitmap.getWidth()),
|
||||
Math.round(crop.top/(float)targetHeight*bitmap.getHeight()),
|
||||
Math.round(crop.right/(float)targetWidth*bitmap.getWidth()),
|
||||
Math.round(crop.bottom/(float)targetHeight*bitmap.getHeight())
|
||||
);
|
||||
}else{
|
||||
dstBounds=new Rect(0, 0, targetWidth, targetHeight);
|
||||
}
|
||||
Bitmap scaled=Bitmap.createBitmap(dstBounds.width(), dstBounds.height(), Bitmap.Config.ARGB_8888);
|
||||
new Canvas(scaled).drawBitmap(bitmap, srcBounds, dstBounds, new Paint(Paint.FILTER_BITMAP_FLAG));
|
||||
bitmap=scaled;
|
||||
}
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
|
||||
int rotation;
|
||||
int orientation=0;
|
||||
if("file".equals(uri.getScheme())){
|
||||
ExifInterface exif=new ExifInterface(uri.getPath());
|
||||
orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
|
||||
}else if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
|
||||
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
|
||||
ExifInterface exif=new ExifInterface(in);
|
||||
int orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
|
||||
rotation=switch(orientation){
|
||||
case ExifInterface.ORIENTATION_ROTATE_90 -> 90;
|
||||
case ExifInterface.ORIENTATION_ROTATE_180 -> 180;
|
||||
case ExifInterface.ORIENTATION_ROTATE_270 -> 270;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
if(rotation!=0){
|
||||
Matrix matrix=new Matrix();
|
||||
matrix.setRotate(rotation);
|
||||
bitmap=Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
|
||||
orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
|
||||
}
|
||||
}
|
||||
int rotation=switch(orientation){
|
||||
case ExifInterface.ORIENTATION_ROTATE_90 -> 90;
|
||||
case ExifInterface.ORIENTATION_ROTATE_180 -> 180;
|
||||
case ExifInterface.ORIENTATION_ROTATE_270 -> 270;
|
||||
default -> 0;
|
||||
};
|
||||
if(rotation!=0){
|
||||
Matrix matrix=new Matrix();
|
||||
matrix.setRotate(rotation);
|
||||
bitmap=Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
|
||||
}
|
||||
}
|
||||
|
||||
tempFile=new File(MastodonApp.context.getCacheDir(), "tmp_upload_image");
|
||||
boolean isPNG="image/png".equals(contentType);
|
||||
tempFile=File.createTempFile("mastodon_tmp_resized", null);
|
||||
try(FileOutputStream out=new FileOutputStream(tempFile)){
|
||||
if("image/png".equals(contentType)){
|
||||
if(isPNG){
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 0, out);
|
||||
}else{
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 97, out);
|
||||
@@ -94,9 +138,13 @@ public class ResizedImageRequestBody extends CountingRequestBody{
|
||||
}
|
||||
length=tempFile.length();
|
||||
}else{
|
||||
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
|
||||
cursor.moveToFirst();
|
||||
length=cursor.getInt(0);
|
||||
if("file".equals(uri.getScheme())){
|
||||
length=new File(uri.getPath()).length();
|
||||
}else{
|
||||
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
|
||||
cursor.moveToFirst();
|
||||
length=cursor.getInt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,4 +173,22 @@ public class ResizedImageRequestBody extends CountingRequestBody{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected int[] getTargetSize(int srcWidth, int srcHeight){
|
||||
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)srcWidth/srcHeight)));
|
||||
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)srcHeight/srcWidth)));
|
||||
return new int[]{targetWidth, targetHeight};
|
||||
}
|
||||
|
||||
protected boolean needResize(int srcWidth, int srcHeight){
|
||||
return srcWidth*srcHeight>maxSize;
|
||||
}
|
||||
|
||||
protected boolean needCrop(int srcWidth, int srcHeight){
|
||||
return false;
|
||||
}
|
||||
|
||||
protected Rect getCropBounds(int srcWidth, int srcHeight){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ public class StatusInteractionController{
|
||||
status.favouritesCount++;
|
||||
else
|
||||
status.favouritesCount--;
|
||||
E.post(new StatusCountersUpdatedEvent(status));
|
||||
}
|
||||
|
||||
public void setReblogged(Status status, boolean reblogged){
|
||||
@@ -95,5 +96,6 @@ public class StatusInteractionController{
|
||||
status.reblogsCount++;
|
||||
else
|
||||
status.reblogsCount--;
|
||||
E.post(new StatusCountersUpdatedEvent(status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.joinmastodon.android.api.requests;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.HeaderPaginationList;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import okhttp3.Response;
|
||||
|
||||
public abstract class HeaderPaginationRequest<I> extends MastodonAPIRequest<HeaderPaginationList<I>>{
|
||||
private static final Pattern LINK_HEADER_PATTERN=Pattern.compile("(?:(?:,\\s*)?<([^>]+)>|;\\s*(\\w+)=['\"](\\w+)['\"])");
|
||||
|
||||
public HeaderPaginationRequest(HttpMethod method, String path, Class<HeaderPaginationList<I>> respClass){
|
||||
super(method, path, respClass);
|
||||
}
|
||||
|
||||
public HeaderPaginationRequest(HttpMethod method, String path, TypeToken<HeaderPaginationList<I>> respTypeToken){
|
||||
super(method, path, respTypeToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateAndPostprocessResponse(HeaderPaginationList<I> respObj, Response httpResponse) throws IOException{
|
||||
super.validateAndPostprocessResponse(respObj, httpResponse);
|
||||
String link=httpResponse.header("Link");
|
||||
if(!TextUtils.isEmpty(link)){
|
||||
Matcher matcher=LINK_HEADER_PATTERN.matcher(link);
|
||||
String url=null;
|
||||
while(matcher.find()){
|
||||
if(url==null){
|
||||
String _url=matcher.group(1);
|
||||
if(_url==null)
|
||||
continue;
|
||||
url=_url;
|
||||
}else{
|
||||
String paramName=matcher.group(2);
|
||||
String paramValue=matcher.group(3);
|
||||
if(paramName==null || paramValue==null)
|
||||
return;
|
||||
if("rel".equals(paramName)){
|
||||
switch(paramValue){
|
||||
case "next" -> respObj.nextPageUri=Uri.parse(url);
|
||||
case "prev" -> respObj.prevPageUri=Uri.parse(url);
|
||||
}
|
||||
url=null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.joinmastodon.android.api.requests.accounts;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
|
||||
public class GetAccountFollowers extends HeaderPaginationRequest<Account>{
|
||||
public GetAccountFollowers(String id, String maxID, int limit){
|
||||
super(HttpMethod.GET, "/accounts/"+id+"/followers", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
if(limit>0)
|
||||
addQueryParameter("limit", limit+"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.joinmastodon.android.api.requests.accounts;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
|
||||
public class GetAccountFollowing extends HeaderPaginationRequest<Account>{
|
||||
public GetAccountFollowing(String id, String maxID, int limit){
|
||||
super(HttpMethod.GET, "/accounts/"+id+"/following", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
if(limit>0)
|
||||
addQueryParameter("limit", limit+"");
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@ package org.joinmastodon.android.api.requests.accounts;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import org.joinmastodon.android.api.AvatarResizedImageRequestBody;
|
||||
import org.joinmastodon.android.api.ContentUriRequestBody;
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.api.ResizedImageRequestBody;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.AccountField;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.MultipartBody;
|
||||
@@ -39,21 +42,21 @@ public class UpdateAccountCredentials extends MastodonAPIRequest<Account>{
|
||||
}
|
||||
|
||||
@Override
|
||||
public RequestBody getRequestBody(){
|
||||
public RequestBody getRequestBody() throws IOException{
|
||||
MultipartBody.Builder bldr=new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("display_name", displayName)
|
||||
.addFormDataPart("note", bio);
|
||||
|
||||
if(avatar!=null){
|
||||
bldr.addFormDataPart("avatar", UiUtils.getFileName(avatar), new ContentUriRequestBody(avatar, null));
|
||||
bldr.addFormDataPart("avatar", UiUtils.getFileName(avatar), new AvatarResizedImageRequestBody(avatar, null));
|
||||
}else if(avatarFile!=null){
|
||||
bldr.addFormDataPart("avatar", avatarFile.getName(), RequestBody.create(UiUtils.getFileMediaType(avatarFile), avatarFile));
|
||||
bldr.addFormDataPart("avatar", avatarFile.getName(), new AvatarResizedImageRequestBody(Uri.fromFile(avatarFile), null));
|
||||
}
|
||||
if(cover!=null){
|
||||
bldr.addFormDataPart("header", UiUtils.getFileName(cover), new ContentUriRequestBody(cover, null));
|
||||
bldr.addFormDataPart("header", UiUtils.getFileName(cover), new ResizedImageRequestBody(cover, 1500*500, null));
|
||||
}else if(coverFile!=null){
|
||||
bldr.addFormDataPart("header", coverFile.getName(), RequestBody.create(UiUtils.getFileMediaType(coverFile), coverFile));
|
||||
bldr.addFormDataPart("header", coverFile.getName(), new ResizedImageRequestBody(Uri.fromFile(coverFile), 1500*500, null));
|
||||
}
|
||||
if(fields.isEmpty()){
|
||||
bldr.addFormDataPart("fields_attributes[0][name]", "").addFormDataPart("fields_attributes[0][value]", "");
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.joinmastodon.android.api.requests.statuses;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
|
||||
public class GetStatusFavorites extends HeaderPaginationRequest<Account>{
|
||||
public GetStatusFavorites(String id, String maxID, int limit){
|
||||
super(HttpMethod.GET, "/statuses/"+id+"/favourited_by", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
if(limit>0)
|
||||
addQueryParameter("limit", limit+"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.joinmastodon.android.api.requests.statuses;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
|
||||
public class GetStatusReblogs extends HeaderPaginationRequest<Account>{
|
||||
public GetStatusReblogs(String id, String maxID, int limit){
|
||||
super(HttpMethod.GET, "/statuses/"+id+"/reblogged_by", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
if(limit>0)
|
||||
addQueryParameter("limit", limit+"");
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,14 @@ import org.joinmastodon.android.model.Status;
|
||||
import java.util.List;
|
||||
|
||||
public class GetHomeTimeline extends MastodonAPIRequest<List<Status>>{
|
||||
public GetHomeTimeline(String maxID, String minID, int limit){
|
||||
public GetHomeTimeline(String maxID, String minID, int limit, String sinceID){
|
||||
super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
if(minID!=null)
|
||||
addQueryParameter("min_id", minID);
|
||||
if(sinceID!=null)
|
||||
addQueryParameter("since_id", sinceID);
|
||||
if(limit>0)
|
||||
addQueryParameter("limit", ""+limit);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public class AccountSession{
|
||||
public boolean needUpdatePushSettings;
|
||||
public long filtersLastUpdated;
|
||||
public List<Filter> wordFilters=new ArrayList<>();
|
||||
public String pushAccountID;
|
||||
private transient MastodonAPIController apiController;
|
||||
private transient StatusInteractionController statusInteractionController;
|
||||
private transient CacheController cacheController;
|
||||
|
||||
@@ -2,15 +2,22 @@ package org.joinmastodon.android.api.session;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ShortcutInfo;
|
||||
import android.content.pm.ShortcutManager;
|
||||
import android.graphics.drawable.Icon;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
|
||||
import org.joinmastodon.android.BuildConfig;
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.MastodonAPIController;
|
||||
@@ -85,11 +92,12 @@ public class AccountSessionManager{
|
||||
domains.add(session.domain.toLowerCase());
|
||||
sessions.put(session.getID(), session);
|
||||
}
|
||||
}catch(IOException|JsonParseException x){
|
||||
}catch(Exception x){
|
||||
Log.e(TAG, "Error loading accounts", x);
|
||||
}
|
||||
lastActiveAccountID=prefs.getString("lastActiveAccount", null);
|
||||
MastodonAPIController.runInBackground(()->readInstanceInfo(domains));
|
||||
maybeUpdateShortcuts();
|
||||
}
|
||||
|
||||
public void addAccount(Instance instance, Token token, Account self, Application app, boolean active){
|
||||
@@ -100,8 +108,9 @@ public class AccountSessionManager{
|
||||
writeAccountsFile();
|
||||
updateInstanceEmojis(instance, instance.uri);
|
||||
if(PushSubscriptionManager.arePushNotificationsAvailable()){
|
||||
session.getPushSubscriptionManager().registerAccountForPush();
|
||||
session.getPushSubscriptionManager().registerAccountForPush(null);
|
||||
}
|
||||
maybeUpdateShortcuts();
|
||||
}
|
||||
|
||||
public synchronized void writeAccountsFile(){
|
||||
@@ -133,10 +142,20 @@ public class AccountSessionManager{
|
||||
return session;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public AccountSession tryGetAccount(String id){
|
||||
return sessions.get(id);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public AccountSession getLastActiveAccount(){
|
||||
if(sessions.isEmpty() || lastActiveAccountID==null)
|
||||
return null;
|
||||
if(!sessions.containsKey(lastActiveAccountID)){
|
||||
// TODO figure out why this happens. It should not be possible.
|
||||
lastActiveAccountID=getLoggedInAccounts().get(0).getID();
|
||||
writeAccountsFile();
|
||||
}
|
||||
return getAccount(lastActiveAccountID);
|
||||
}
|
||||
|
||||
@@ -171,6 +190,7 @@ public class AccountSessionManager{
|
||||
NotificationManager nm=MastodonApp.context.getSystemService(NotificationManager.class);
|
||||
nm.deleteNotificationChannelGroup(id);
|
||||
}
|
||||
maybeUpdateShortcuts();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@@ -197,6 +217,7 @@ public class AccountSessionManager{
|
||||
|
||||
new CustomTabsIntent.Builder()
|
||||
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
|
||||
.setShowTitle(true)
|
||||
.build()
|
||||
.launchUrl(activity, uri);
|
||||
}
|
||||
@@ -347,7 +368,7 @@ public class AccountSessionManager{
|
||||
customEmojis.put(domain, groupCustomEmojis(emojis));
|
||||
instances.put(domain, emojis.instance);
|
||||
instancesLastUpdated.put(domain, emojis.lastUpdated);
|
||||
}catch(IOException|JsonParseException x){
|
||||
}catch(Exception x){
|
||||
Log.w(TAG, "Error reading instance info file for "+domain, x);
|
||||
}
|
||||
}
|
||||
@@ -384,6 +405,29 @@ public class AccountSessionManager{
|
||||
writeAccountsFile();
|
||||
}
|
||||
|
||||
private void maybeUpdateShortcuts(){
|
||||
if(Build.VERSION.SDK_INT<26)
|
||||
return;
|
||||
ShortcutManager sm=MastodonApp.context.getSystemService(ShortcutManager.class);
|
||||
if((sm.getDynamicShortcuts().isEmpty() || BuildConfig.DEBUG) && !sessions.isEmpty()){
|
||||
// There are no shortcuts, but there are accounts. Add a compose shortcut.
|
||||
ShortcutInfo info=new ShortcutInfo.Builder(MastodonApp.context, "compose")
|
||||
.setActivity(ComponentName.createRelative(MastodonApp.context, MainActivity.class.getName()))
|
||||
.setShortLabel(MastodonApp.context.getString(R.string.new_post))
|
||||
.setIcon(Icon.createWithResource(MastodonApp.context, R.mipmap.ic_shortcut_compose))
|
||||
.setIntent(new Intent(MastodonApp.context, MainActivity.class)
|
||||
.setAction(Intent.ACTION_MAIN)
|
||||
.putExtra("compose", true))
|
||||
.build();
|
||||
sm.setDynamicShortcuts(Collections.singletonList(info));
|
||||
}else if(sessions.isEmpty()){
|
||||
// There are shortcuts, but no accounts. Disable existing shortcuts.
|
||||
sm.disableShortcuts(Collections.singletonList("compose"), MastodonApp.context.getString(R.string.err_not_logged_in));
|
||||
}else{
|
||||
sm.enableShortcuts(Collections.singletonList("compose"));
|
||||
}
|
||||
}
|
||||
|
||||
private static class SessionsStorageWrapper{
|
||||
public List<AccountSession> accounts;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ public class AccountTimelineFragment extends StatusListFragment{
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
if(user==null) // TODO figure out why this happens
|
||||
return;
|
||||
currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
|
||||
@@ -31,6 +31,8 @@ import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.BetterItemAnimator;
|
||||
import org.joinmastodon.android.ui.PhotoLayoutHelper;
|
||||
import org.joinmastodon.android.ui.TileGridLayoutManager;
|
||||
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem;
|
||||
@@ -281,6 +283,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
list.getDecoratedBoundsWithMargins(view, outRect);
|
||||
RecyclerView.ViewHolder holder=list.getChildViewHolder(view);
|
||||
if(holder instanceof StatusDisplayItem.Holder){
|
||||
if(((StatusDisplayItem.Holder<?>) holder).getItem().getType()==StatusDisplayItem.Type.GAP){
|
||||
outRect.setEmpty();
|
||||
return;
|
||||
}
|
||||
String id=((StatusDisplayItem.Holder<?>) holder).getItemID();
|
||||
for(int i=0;i<list.getChildCount();i++){
|
||||
View child=list.getChildAt(i);
|
||||
@@ -358,6 +364,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
public abstract void onItemClick(String id);
|
||||
|
||||
protected void updatePoll(String itemID, Status status, Poll poll){
|
||||
status.poll=poll;
|
||||
int firstOptionIndex=-1, footerIndex=-1;
|
||||
int i=0;
|
||||
for(StatusDisplayItem item:displayItems){
|
||||
@@ -454,9 +461,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
|
||||
if(header!=null)
|
||||
header.rebind();
|
||||
for(ImageStatusDisplayItem.Holder photo:(List<ImageStatusDisplayItem.Holder>)findAllHoldersOfType(itemID, ImageStatusDisplayItem.Holder.class)){
|
||||
photo.setRevealed(true);
|
||||
}
|
||||
updateImagesSpoilerState(status, itemID);
|
||||
}
|
||||
|
||||
public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder){
|
||||
@@ -465,15 +470,30 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
if(!TextUtils.isEmpty(status.spoilerText)){
|
||||
TextStatusDisplayItem.Holder text=findHolderOfType(holder.getItemID(), TextStatusDisplayItem.Holder.class);
|
||||
if(text!=null){
|
||||
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()+getMainAdapterOffset());
|
||||
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition());
|
||||
}
|
||||
}
|
||||
holder.rebind();
|
||||
for(ImageStatusDisplayItem.Holder<?> photo:(List<ImageStatusDisplayItem.Holder>)findAllHoldersOfType(holder.getItemID(), ImageStatusDisplayItem.Holder.class)){
|
||||
updateImagesSpoilerState(status, holder.getItemID());
|
||||
}
|
||||
|
||||
protected void updateImagesSpoilerState(Status status, String itemID){
|
||||
ArrayList<Integer> updatedPositions=new ArrayList<>();
|
||||
for(ImageStatusDisplayItem.Holder photo:(List<ImageStatusDisplayItem.Holder>)findAllHoldersOfType(itemID, ImageStatusDisplayItem.Holder.class)){
|
||||
photo.setRevealed(status.spoilerRevealed);
|
||||
updatedPositions.add(photo.getAbsoluteAdapterPosition()-getMainAdapterOffset());
|
||||
}
|
||||
int i=0;
|
||||
for(StatusDisplayItem item:displayItems){
|
||||
if(itemID.equals(item.parentID) && item instanceof ImageStatusDisplayItem && !updatedPositions.contains(i)){
|
||||
adapter.notifyItemChanged(i);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
public void onGapClick(GapStatusDisplayItem.Holder item){}
|
||||
|
||||
public String getAccountID(){
|
||||
return accountID;
|
||||
}
|
||||
@@ -653,8 +673,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
View bottomSibling=parent.getChildAt(i+1);
|
||||
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
|
||||
RecyclerView.ViewHolder siblingHolder=parent.getChildViewHolder(bottomSibling);
|
||||
if(holder instanceof StatusDisplayItem.Holder && siblingHolder instanceof StatusDisplayItem.Holder
|
||||
&& !((StatusDisplayItem.Holder<?>) holder).getItemID().equals(((StatusDisplayItem.Holder<?>) siblingHolder).getItemID())){
|
||||
if(holder instanceof StatusDisplayItem.Holder<?> ih && siblingHolder instanceof StatusDisplayItem.Holder<?> sh
|
||||
&& (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP){
|
||||
drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, dividerPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,6 +382,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count){
|
||||
if(s.length()==0)
|
||||
return;
|
||||
// offset one char back to catch an already typed '@' or '#' or ':'
|
||||
int realStart=start;
|
||||
start=Math.max(0, start-1);
|
||||
@@ -447,7 +449,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
if(!mentions.contains(m))
|
||||
mentions.add(m);
|
||||
}
|
||||
initialText=TextUtils.join(" ", mentions)+" ";
|
||||
initialText=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" ";
|
||||
if(savedInstanceState==null){
|
||||
mainEditText.setText(initialText);
|
||||
mainEditText.setSelection(mainEditText.length());
|
||||
@@ -607,17 +609,19 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
@Override
|
||||
public void onSuccess(Status result){
|
||||
wm.removeView(sendingOverlay);
|
||||
Nav.finish(ComposeFragment.this);
|
||||
sendingOverlay=null;
|
||||
E.post(new StatusCreatedEvent(result));
|
||||
if(replyTo!=null){
|
||||
replyTo.repliesCount++;
|
||||
E.post(new StatusCountersUpdatedEvent(replyTo));
|
||||
}
|
||||
Nav.finish(ComposeFragment.this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
wm.removeView(sendingOverlay);
|
||||
sendingOverlay=null;
|
||||
sendProgress.setVisibility(View.GONE);
|
||||
sendError.setVisibility(View.VISIBLE);
|
||||
publishButton.setEnabled(true);
|
||||
@@ -645,6 +649,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
confirmDiscardDraftAndFinish();
|
||||
return true;
|
||||
}
|
||||
if(sendingOverlay!=null)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -714,25 +720,27 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
return false;
|
||||
}
|
||||
String type=getActivity().getContentResolver().getType(uri);
|
||||
if(instance.configuration!=null && instance.configuration.mediaAttachments!=null){
|
||||
if(instance!=null && instance.configuration!=null && instance.configuration.mediaAttachments!=null){
|
||||
if(instance.configuration.mediaAttachments.supportedMimeTypes!=null && !instance.configuration.mediaAttachments.supportedMimeTypes.contains(type)){
|
||||
showMediaAttachmentError(getString(R.string.media_attachment_unsupported_type, UiUtils.getFileName(uri)));
|
||||
return false;
|
||||
}
|
||||
int sizeLimit=type.startsWith("image/") ? instance.configuration.mediaAttachments.imageSizeLimit : instance.configuration.mediaAttachments.videoSizeLimit;
|
||||
int size;
|
||||
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
|
||||
cursor.moveToFirst();
|
||||
size=cursor.getInt(0);
|
||||
}catch(Exception x){
|
||||
Log.w("ComposeFragment", x);
|
||||
return false;
|
||||
}
|
||||
if(size>sizeLimit){
|
||||
float mb=sizeLimit/(float)(1024*1024);
|
||||
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb);
|
||||
showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
|
||||
return false;
|
||||
if(!type.startsWith("image/")){
|
||||
int sizeLimit=instance.configuration.mediaAttachments.videoSizeLimit;
|
||||
int size;
|
||||
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
|
||||
cursor.moveToFirst();
|
||||
size=cursor.getInt(0);
|
||||
}catch(Exception x){
|
||||
Log.w("ComposeFragment", x);
|
||||
return false;
|
||||
}
|
||||
if(size>sizeLimit){
|
||||
float mb=sizeLimit/(float) (1024*1024);
|
||||
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb);
|
||||
showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
pollBtn.setEnabled(false);
|
||||
@@ -1049,6 +1057,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
}
|
||||
|
||||
private void updateVisibilityIcon(){
|
||||
if(statusVisibility==null){ // TODO find out why this happens
|
||||
statusVisibility=StatusPrivacy.PUBLIC;
|
||||
}
|
||||
visibilityBtn.setImageResource(switch(statusVisibility){
|
||||
case PUBLIC -> R.drawable.ic_fluent_earth_24_filled;
|
||||
case UNLISTED -> R.drawable.ic_fluent_people_community_24_regular;
|
||||
@@ -1059,7 +1070,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged(int start, int end){
|
||||
if(start==end){
|
||||
if(start==end && mainEditText.length()>0){
|
||||
ComposeAutocompleteSpan[] spans=mainEditText.getText().getSpans(start, end, ComposeAutocompleteSpan.class);
|
||||
if(spans.length>0){
|
||||
assert spans.length==1;
|
||||
@@ -1093,7 +1104,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
|
||||
@Override
|
||||
public String[] onGetAllowedMediaMimeTypes(){
|
||||
if(instance.configuration!=null && instance.configuration.mediaAttachments!=null && instance.configuration.mediaAttachments.supportedMimeTypes!=null)
|
||||
if(instance!=null && instance.configuration!=null && instance.configuration.mediaAttachments!=null && instance.configuration.mediaAttachments.supportedMimeTypes!=null)
|
||||
return instance.configuration.mediaAttachments.supportedMimeTypes.toArray(new String[0]);
|
||||
return new String[]{"image/jpeg", "image/gif", "image/png", "video/mp4"};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.app.NotificationManager;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Outline;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
@@ -17,6 +18,7 @@ import android.view.WindowInsets;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
@@ -28,6 +30,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.discover.DiscoverFragment;
|
||||
import org.joinmastodon.android.fragments.discover.SearchFragment;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.ui.AccountSwitcherSheet;
|
||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.views.TabBar;
|
||||
@@ -46,6 +49,7 @@ import me.grishka.appkit.fragments.OnBackPressedListener;
|
||||
import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.BottomSheet;
|
||||
import me.grishka.appkit.views.FragmentRootLinearLayout;
|
||||
|
||||
public class HomeFragment extends AppKitFragment implements OnBackPressedListener{
|
||||
@@ -238,21 +242,11 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
|
||||
|
||||
private boolean onTabLongClick(@IdRes int tab){
|
||||
if(tab==R.id.tab_profile){
|
||||
ArrayList<String> options=new ArrayList<>();
|
||||
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
|
||||
options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")");
|
||||
}
|
||||
new M3AlertDialogBuilder(getActivity())
|
||||
.setItems(options.toArray(new String[0]), (dialog, which)->{
|
||||
AccountSession session=AccountSessionManager.getInstance().getLoggedInAccounts().get(which);
|
||||
AccountSessionManager.getInstance().setLastActiveAccountID(session.getID());
|
||||
getActivity().finish();
|
||||
getActivity().startActivity(new Intent(getActivity(), MainActivity.class));
|
||||
})
|
||||
.setNegativeButton(R.string.add_account, (dialog, which)->{
|
||||
Nav.go(getActivity(), SplashFragment.class, null);
|
||||
})
|
||||
.show();
|
||||
ArrayList<String> options=new ArrayList<>();
|
||||
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
|
||||
options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")");
|
||||
}
|
||||
new AccountSwitcherSheet(getActivity()).show();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.AnimatorSet;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.app.Activity;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.Gravity;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.Toolbar;
|
||||
@@ -16,20 +24,38 @@ import android.widget.Toolbar;
|
||||
import com.squareup.otto.Subscribe;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.events.StatusCreatedEvent;
|
||||
import org.joinmastodon.android.model.PaginatedResponse;
|
||||
import org.joinmastodon.android.model.CacheablePaginatedResponse;
|
||||
import org.joinmastodon.android.model.Filter;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.utils.StatusFilterPredicate;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
public class HomeTimelineFragment extends StatusListFragment{
|
||||
private ImageButton fab;
|
||||
private ImageView toolbarLogo;
|
||||
private Button toolbarShowNewPostsBtn;
|
||||
private boolean newPostsBtnShown;
|
||||
private AnimatorSet currentNewPostsAnim;
|
||||
|
||||
private String maxID;
|
||||
|
||||
@@ -50,11 +76,13 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
.getAccount(accountID).getCacheController()
|
||||
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(PaginatedResponse<List<Status>> result){
|
||||
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
onDataLoaded(result.items, !result.items.isEmpty());
|
||||
maxID=result.maxID;
|
||||
if(result.isFromCache())
|
||||
loadNewPosts();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -65,6 +93,14 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
fab=view.findViewById(R.id.fab);
|
||||
fab.setOnClickListener(this::onFabClick);
|
||||
updateToolbarLogo();
|
||||
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
|
||||
@Override
|
||||
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
|
||||
if(newPostsBtnShown && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){
|
||||
hideNewPostsButton();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -89,8 +125,13 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
@Override
|
||||
protected void onShown(){
|
||||
super.onShown();
|
||||
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
|
||||
loadData();
|
||||
if(!getArguments().getBoolean("noAutoLoad")){
|
||||
if(!loaded && !dataLoading){
|
||||
loadData();
|
||||
}else if(!dataLoading){
|
||||
loadNewPosts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
@@ -104,12 +145,256 @@ public class HomeTimelineFragment extends StatusListFragment{
|
||||
Nav.go(getActivity(), ComposeFragment.class, args);
|
||||
}
|
||||
|
||||
private void loadNewPosts(){
|
||||
dataLoading=true;
|
||||
// The idea here is that we request the timeline such that if there are fewer than `limit` posts,
|
||||
// we'll get the currently topmost post as last in the response. This way we know there's no gap
|
||||
// between the existing and newly loaded parts of the timeline.
|
||||
String sinceID=data.size()>1 ? data.get(1).id : "1";
|
||||
currentRequest=new GetHomeTimeline(null, null, 20, sinceID)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
currentRequest=null;
|
||||
dataLoading=false;
|
||||
if(result.isEmpty() || getActivity()==null)
|
||||
return;
|
||||
Status last=result.get(result.size()-1);
|
||||
List<Status> toAdd;
|
||||
if(!data.isEmpty() && last.id.equals(data.get(0).id)){ // This part intersects with the existing one
|
||||
toAdd=result.subList(0, result.size()-1); // Remove the already known last post
|
||||
}else{
|
||||
result.get(result.size()-1).hasGapAfter=true;
|
||||
toAdd=result;
|
||||
}
|
||||
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
|
||||
if(!filters.isEmpty()){
|
||||
toAdd=toAdd.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList());
|
||||
}
|
||||
if(!toAdd.isEmpty()){
|
||||
prependItems(toAdd, true);
|
||||
showNewPostsButton();
|
||||
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
currentRequest=null;
|
||||
dataLoading=false;
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGapClick(GapStatusDisplayItem.Holder item){
|
||||
if(dataLoading)
|
||||
return;
|
||||
item.getItem().loading=true;
|
||||
V.setVisibilityAnimated(item.progress, View.VISIBLE);
|
||||
V.setVisibilityAnimated(item.text, View.GONE);
|
||||
GapStatusDisplayItem gap=item.getItem();
|
||||
dataLoading=true;
|
||||
currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
currentRequest=null;
|
||||
dataLoading=false;
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
int gapPos=displayItems.indexOf(gap);
|
||||
if(gapPos==-1)
|
||||
return;
|
||||
if(result.isEmpty()){
|
||||
displayItems.remove(gapPos);
|
||||
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
|
||||
Status gapStatus=getStatusByID(gap.parentID);
|
||||
if(gapStatus!=null){
|
||||
gapStatus.hasGapAfter=false;
|
||||
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
|
||||
}
|
||||
}else{
|
||||
Set<String> idsBelowGap=new HashSet<>();
|
||||
boolean belowGap=false;
|
||||
int gapPostIndex=0;
|
||||
for(Status s:data){
|
||||
if(belowGap){
|
||||
idsBelowGap.add(s.id);
|
||||
}else if(s.id.equals(gap.parentID)){
|
||||
belowGap=true;
|
||||
s.hasGapAfter=false;
|
||||
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false);
|
||||
}else{
|
||||
gapPostIndex++;
|
||||
}
|
||||
}
|
||||
int endIndex=0;
|
||||
for(Status s:result){
|
||||
endIndex++;
|
||||
if(idsBelowGap.contains(s.id))
|
||||
break;
|
||||
}
|
||||
if(endIndex==result.size()){
|
||||
result.get(result.size()-1).hasGapAfter=true;
|
||||
}else{
|
||||
result=result.subList(0, endIndex);
|
||||
}
|
||||
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
|
||||
targetList.clear();
|
||||
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
|
||||
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
|
||||
outer:
|
||||
for(Status s:result){
|
||||
if(idsBelowGap.contains(s.id))
|
||||
break;
|
||||
for(Filter filter:filters){
|
||||
if(filter.matches(s.getContentStatus().content)){
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
targetList.addAll(buildDisplayItems(s));
|
||||
insertedPosts.add(s);
|
||||
}
|
||||
if(targetList.isEmpty()){
|
||||
// oops. We didn't add new posts, but at least we know there are none.
|
||||
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
|
||||
}else{
|
||||
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
|
||||
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
|
||||
}
|
||||
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
currentRequest=null;
|
||||
dataLoading=false;
|
||||
gap.loading=false;
|
||||
Activity a=getActivity();
|
||||
if(a!=null){
|
||||
error.showToast(a);
|
||||
int gapPos=displayItems.indexOf(gap);
|
||||
if(gapPos>=0)
|
||||
adapter.notifyItemChanged(gapPos);
|
||||
}
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefresh(){
|
||||
if(currentRequest!=null){
|
||||
currentRequest.cancel();
|
||||
currentRequest=null;
|
||||
dataLoading=false;
|
||||
}
|
||||
super.onRefresh();
|
||||
}
|
||||
|
||||
private void updateToolbarLogo(){
|
||||
ImageView logo=new ImageView(getActivity());
|
||||
logo.setScaleType(ImageView.ScaleType.CENTER);
|
||||
logo.setImageResource(R.drawable.logo);
|
||||
logo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)));
|
||||
toolbarLogo=new ImageView(getActivity());
|
||||
toolbarLogo.setScaleType(ImageView.ScaleType.CENTER);
|
||||
toolbarLogo.setImageResource(R.drawable.logo);
|
||||
toolbarLogo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)));
|
||||
|
||||
toolbarShowNewPostsBtn=new Button(getActivity());
|
||||
toolbarShowNewPostsBtn.setTextAppearance(R.style.m3_title_medium);
|
||||
toolbarShowNewPostsBtn.setTextColor(0xffffffff);
|
||||
toolbarShowNewPostsBtn.setStateListAnimator(null);
|
||||
toolbarShowNewPostsBtn.setBackgroundResource(R.drawable.bg_button_new_posts);
|
||||
toolbarShowNewPostsBtn.setText(R.string.see_new_posts);
|
||||
toolbarShowNewPostsBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_fluent_arrow_up_16_filled, 0, 0, 0);
|
||||
toolbarShowNewPostsBtn.setCompoundDrawableTintList(toolbarShowNewPostsBtn.getTextColors());
|
||||
toolbarShowNewPostsBtn.setCompoundDrawablePadding(V.dp(8));
|
||||
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N)
|
||||
UiUtils.fixCompoundDrawableTintOnAndroid6(toolbarShowNewPostsBtn);
|
||||
toolbarShowNewPostsBtn.setOnClickListener(this::onNewPostsBtnClick);
|
||||
|
||||
if(newPostsBtnShown){
|
||||
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
|
||||
toolbarLogo.setVisibility(View.INVISIBLE);
|
||||
toolbarLogo.setAlpha(0f);
|
||||
}else{
|
||||
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
|
||||
toolbarShowNewPostsBtn.setAlpha(0f);
|
||||
toolbarShowNewPostsBtn.setScaleX(.8f);
|
||||
toolbarShowNewPostsBtn.setScaleY(.8f);
|
||||
toolbarLogo.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
FrameLayout logoWrap=new FrameLayout(getActivity());
|
||||
logoWrap.addView(toolbarLogo, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER));
|
||||
logoWrap.addView(toolbarShowNewPostsBtn, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, V.dp(32), Gravity.CENTER));
|
||||
|
||||
Toolbar toolbar=getToolbar();
|
||||
toolbar.addView(logo, new Toolbar.LayoutParams(Gravity.CENTER));
|
||||
toolbar.addView(logoWrap, new Toolbar.LayoutParams(Gravity.CENTER));
|
||||
}
|
||||
|
||||
private void showNewPostsButton(){
|
||||
if(newPostsBtnShown)
|
||||
return;
|
||||
newPostsBtnShown=true;
|
||||
if(currentNewPostsAnim!=null){
|
||||
currentNewPostsAnim.cancel();
|
||||
}
|
||||
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
|
||||
AnimatorSet set=new AnimatorSet();
|
||||
set.playTogether(
|
||||
ObjectAnimator.ofFloat(toolbarLogo, View.ALPHA, 0f),
|
||||
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 1f),
|
||||
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, 1f),
|
||||
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, 1f)
|
||||
);
|
||||
set.setDuration(300);
|
||||
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||
set.addListener(new AnimatorListenerAdapter(){
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation){
|
||||
toolbarLogo.setVisibility(View.INVISIBLE);
|
||||
currentNewPostsAnim=null;
|
||||
}
|
||||
});
|
||||
currentNewPostsAnim=set;
|
||||
set.start();
|
||||
}
|
||||
|
||||
private void hideNewPostsButton(){
|
||||
if(!newPostsBtnShown)
|
||||
return;
|
||||
newPostsBtnShown=false;
|
||||
if(currentNewPostsAnim!=null){
|
||||
currentNewPostsAnim.cancel();
|
||||
}
|
||||
toolbarLogo.setVisibility(View.VISIBLE);
|
||||
AnimatorSet set=new AnimatorSet();
|
||||
set.playTogether(
|
||||
ObjectAnimator.ofFloat(toolbarLogo, View.ALPHA, 1f),
|
||||
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 0f),
|
||||
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, .8f),
|
||||
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, .8f)
|
||||
);
|
||||
set.setDuration(300);
|
||||
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||
set.addListener(new AnimatorListenerAdapter(){
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation){
|
||||
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
|
||||
currentNewPostsAnim=null;
|
||||
}
|
||||
});
|
||||
currentNewPostsAnim=set;
|
||||
set.start();
|
||||
}
|
||||
|
||||
private void onNewPostsBtnClick(View v){
|
||||
if(newPostsBtnShown){
|
||||
hideNewPostsButton();
|
||||
scrollToTop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import android.app.Fragment;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Outline;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.ImageSpan;
|
||||
import android.view.Gravity;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
@@ -42,12 +45,17 @@ import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
|
||||
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
|
||||
import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.account_list.FollowerListFragment;
|
||||
import org.joinmastodon.android.fragments.account_list.FollowingListFragment;
|
||||
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.AccountField;
|
||||
import org.joinmastodon.android.model.Attachment;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
import org.joinmastodon.android.ui.SimpleViewHolder;
|
||||
import org.joinmastodon.android.ui.SingleImagePhotoViewerListener;
|
||||
import org.joinmastodon.android.ui.drawables.CoverOverlayGradientDrawable;
|
||||
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
|
||||
import org.joinmastodon.android.ui.tabs.TabLayout;
|
||||
import org.joinmastodon.android.ui.tabs.TabLayoutMediator;
|
||||
import org.joinmastodon.android.ui.text.CustomEmojiSpan;
|
||||
@@ -104,6 +112,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
private ProgressBar actionProgress;
|
||||
private FrameLayout[] tabViews;
|
||||
private TabLayoutMediator tabLayoutMediator;
|
||||
private TextView followsYouView;
|
||||
|
||||
private Account account;
|
||||
private String accountID;
|
||||
@@ -118,6 +127,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
private boolean refreshing;
|
||||
private View fab;
|
||||
private WindowInsets childInsets;
|
||||
private PhotoViewer currentPhotoViewer;
|
||||
private boolean editModeLoading;
|
||||
|
||||
public ProfileFragment(){
|
||||
super(R.layout.loader_fragment_overlay_toolbar);
|
||||
@@ -178,6 +189,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
bioEdit=content.findViewById(R.id.bio_edit);
|
||||
actionProgress=content.findViewById(R.id.action_progress);
|
||||
fab=content.findViewById(R.id.fab);
|
||||
followsYouView=content.findViewById(R.id.follows_you);
|
||||
|
||||
avatar.setOutlineProvider(new ViewOutlineProvider(){
|
||||
@Override
|
||||
@@ -257,6 +269,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
fab.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
followersBtn.setOnClickListener(this::onFollowersOrFollowingClick);
|
||||
followingBtn.setOnClickListener(this::onFollowersOrFollowingClick);
|
||||
|
||||
return sizeWrapper;
|
||||
}
|
||||
|
||||
@@ -400,8 +415,25 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
HtmlParser.parseCustomEmoji(ssb, account.emojis);
|
||||
name.setText(ssb);
|
||||
setTitle(ssb);
|
||||
username.setText('@'+account.acct);
|
||||
bio.setText(HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID));
|
||||
if(account.locked){
|
||||
ssb=new SpannableStringBuilder("@");
|
||||
ssb.append(account.acct);
|
||||
ssb.append(" ");
|
||||
Drawable lock=username.getResources().getDrawable(R.drawable.ic_fluent_lock_closed_20_filled, getActivity().getTheme()).mutate();
|
||||
lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight());
|
||||
lock.setTint(username.getCurrentTextColor());
|
||||
ssb.append(getString(R.string.manually_approves_followers), new ImageSpan(lock, ImageSpan.ALIGN_BOTTOM), 0);
|
||||
username.setText(ssb);
|
||||
}else{
|
||||
username.setText('@'+account.acct);
|
||||
}
|
||||
CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
|
||||
if(TextUtils.isEmpty(parsedBio)){
|
||||
bio.setVisibility(View.GONE);
|
||||
}else{
|
||||
bio.setVisibility(View.VISIBLE);
|
||||
bio.setText(parsedBio);
|
||||
}
|
||||
followersCount.setText(UiUtils.abbreviateNumber(account.followersCount));
|
||||
followingCount.setText(UiUtils.abbreviateNumber(account.followingCount));
|
||||
postsCount.setText(UiUtils.abbreviateNumber(account.statusesCount));
|
||||
@@ -477,10 +509,17 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
return;
|
||||
}
|
||||
if(relationship==null)
|
||||
if(relationship==null && !isOwnProfile)
|
||||
return;
|
||||
inflater.inflate(R.menu.profile, menu);
|
||||
menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getDisplayUsername()));
|
||||
if(isOwnProfile){
|
||||
for(int i=0;i<menu.size();i++){
|
||||
MenuItem item=menu.getItem(i);
|
||||
item.setVisible(item.getItemId()==R.id.share);
|
||||
}
|
||||
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()));
|
||||
@@ -565,16 +604,16 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
invalidateOptionsMenu();
|
||||
actionButton.setVisibility(View.VISIBLE);
|
||||
UiUtils.setRelationshipToActionButton(relationship, actionButton);
|
||||
actionProgress.setIndeterminateTintList(actionButton.getTextColors());
|
||||
followsYouView.setVisibility(relationship.followedBy ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){
|
||||
int topBarsH=getToolbar().getHeight()+statusBarHeight;
|
||||
if(scrollY>avatar.getTop()-topBarsH){
|
||||
float avaAlpha=Math.max(1f-((scrollY-(avatar.getTop()-topBarsH))/(float)V.dp(38)), 0f);
|
||||
avatar.setAlpha(avaAlpha);
|
||||
if(scrollY>avatarBorder.getTop()-topBarsH){
|
||||
float avaAlpha=Math.max(1f-((scrollY-(avatarBorder.getTop()-topBarsH))/(float)V.dp(38)), 0f);
|
||||
avatarBorder.setAlpha(avaAlpha);
|
||||
}else{
|
||||
avatar.setAlpha(1f);
|
||||
avatarBorder.setAlpha(1f);
|
||||
}
|
||||
if(scrollY>cover.getHeight()-topBarsH){
|
||||
@@ -596,6 +635,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
toolbarTitleView.setTranslationY(titleTransY);
|
||||
toolbarSubtitleView.setTranslationY(titleTransY);
|
||||
}
|
||||
if(currentPhotoViewer!=null){
|
||||
currentPhotoViewer.offsetView(0, oldScrollY-scrollY);
|
||||
}
|
||||
}
|
||||
|
||||
private Fragment getFragmentForPage(int page){
|
||||
@@ -630,17 +672,26 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
}
|
||||
|
||||
private void loadAccountInfoAndEnterEditMode(){
|
||||
if(editModeLoading)
|
||||
return;
|
||||
editModeLoading=true;
|
||||
setActionProgressVisible(true);
|
||||
new GetOwnAccount()
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Account result){
|
||||
editModeLoading=false;
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
enterEditMode(result);
|
||||
setActionProgressVisible(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
editModeLoading=false;
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
error.showToast(getActivity());
|
||||
setActionProgressVisible(false);
|
||||
}
|
||||
@@ -778,15 +829,38 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Attachment> createFakeAttachments(String url, Drawable drawable){
|
||||
Attachment att=new Attachment();
|
||||
att.type=Attachment.Type.IMAGE;
|
||||
att.url=url;
|
||||
att.meta=new Attachment.Metadata();
|
||||
att.meta.width=drawable.getIntrinsicWidth();
|
||||
att.meta.height=drawable.getIntrinsicHeight();
|
||||
return Collections.singletonList(att);
|
||||
}
|
||||
|
||||
private void onAvatarClick(View v){
|
||||
if(isInEditMode){
|
||||
startImagePicker(AVATAR_RESULT);
|
||||
}else{
|
||||
Drawable ava=avatar.getDrawable();
|
||||
if(ava==null)
|
||||
return;
|
||||
int radius=V.dp(25);
|
||||
currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.avatar, ava), 0,
|
||||
new SingleImagePhotoViewerListener(avatar, avatarBorder, new int[]{radius, radius, radius, radius}, this, ()->currentPhotoViewer=null, ()->ava, null, null));
|
||||
}
|
||||
}
|
||||
|
||||
private void onCoverClick(View v){
|
||||
if(isInEditMode){
|
||||
startImagePicker(COVER_RESULT);
|
||||
}else{
|
||||
Drawable drawable=cover.getDrawable();
|
||||
if(drawable==null || drawable instanceof ColorDrawable)
|
||||
return;
|
||||
currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.header, drawable), 0,
|
||||
new SingleImagePhotoViewerListener(cover, cover, null, this, ()->currentPhotoViewer=null, ()->drawable, ()->avatarBorder.setTranslationZ(2), ()->avatarBorder.setTranslationZ(0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,6 +899,20 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
scrollView.smoothScrollTo(0, 0);
|
||||
}
|
||||
|
||||
private void onFollowersOrFollowingClick(View v){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("targetAccount", Parcels.wrap(account));
|
||||
Class<? extends Fragment> cls;
|
||||
if(v.getId()==R.id.followers_btn)
|
||||
cls=FollowerListFragment.class;
|
||||
else if(v.getId()==R.id.following_btn)
|
||||
cls=FollowingListFragment.class;
|
||||
else
|
||||
return;
|
||||
Nav.go(getActivity(), cls, args);
|
||||
}
|
||||
|
||||
private class ProfilePagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{
|
||||
@NonNull
|
||||
@Override
|
||||
|
||||
@@ -13,6 +13,8 @@ public interface ScrollableToTop{
|
||||
* @param list
|
||||
*/
|
||||
default void smoothScrollRecyclerViewToTop(RecyclerView list){
|
||||
if(list==null) // TODO find out why this happens because it should not be possible
|
||||
return;
|
||||
if(list.getChildCount()>0 && list.getChildAdapterPosition(list.getChildAt(0))>10){
|
||||
list.scrollToPosition(0);
|
||||
list.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
|
||||
|
||||
@@ -10,8 +10,8 @@ import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
|
||||
import org.joinmastodon.android.events.StatusCreatedEvent;
|
||||
import org.joinmastodon.android.events.StatusDeletedEvent;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
@@ -90,16 +90,15 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
|
||||
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
|
||||
if(holder instanceof FooterStatusDisplayItem.Holder footer && footer.getItem().status==s.getContentStatus()){
|
||||
footer.rebind();
|
||||
return;
|
||||
}else if(holder instanceof ExtendedFooterStatusDisplayItem.Holder footer && footer.getItem().status==s.getContentStatus()){
|
||||
footer.rebind();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
for(Status s:preloadedData){
|
||||
if(s.id.equals(ev.id)){
|
||||
s.update(ev);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,10 +112,15 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
|
||||
return;
|
||||
data.remove(status);
|
||||
preloadedData.remove(status);
|
||||
HeaderStatusDisplayItem item=findItemOfType(ev.id, HeaderStatusDisplayItem.class);
|
||||
if(item==null)
|
||||
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 index=displayItems.indexOf(item);
|
||||
int lastIndex;
|
||||
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
|
||||
if(!displayItems.get(lastIndex).parentID.equals(ev.id))
|
||||
|
||||
@@ -11,6 +11,8 @@ import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.Filter;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.model.StatusContext;
|
||||
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
@@ -45,7 +47,10 @@ public class ThreadFragment extends StatusListFragment{
|
||||
for(StatusDisplayItem item:items){
|
||||
if(item instanceof TextStatusDisplayItem text)
|
||||
text.textSelectable=true;
|
||||
else if(item instanceof FooterStatusDisplayItem footer)
|
||||
footer.hideCounts=true;
|
||||
}
|
||||
items.add(new ExtendedFooterStatusDisplayItem(s.id, this, s.getContentStatus()));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -56,6 +61,8 @@ public class ThreadFragment extends StatusListFragment{
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(StatusContext result){
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
if(refreshing){
|
||||
data.clear();
|
||||
displayItems.clear();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.joinmastodon.android.fragments.account_list;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment{
|
||||
protected Account account;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
|
||||
setTitle("@"+account.acct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package org.joinmastodon.android.fragments.account_list;
|
||||
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.drawable.Animatable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowInsets;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.PopupMenu;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toolbar;
|
||||
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
|
||||
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.ProfileFragment;
|
||||
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
import org.joinmastodon.android.ui.DividerItemDecoration;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.APIRequest;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.fragments.BaseRecyclerFragment;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
|
||||
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
|
||||
public abstract class BaseAccountListFragment extends BaseRecyclerFragment<BaseAccountListFragment.AccountItem>{
|
||||
protected HashMap<String, Relationship> relationships=new HashMap<>();
|
||||
protected String accountID;
|
||||
protected ArrayList<APIRequest<?>> relationshipsRequests=new ArrayList<>();
|
||||
|
||||
public BaseAccountListFragment(){
|
||||
super(40);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
accountID=getArguments().getString("account");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDataLoaded(List<AccountItem> d, boolean more){
|
||||
if(refreshing){
|
||||
relationships.clear();
|
||||
}
|
||||
loadRelationships(d);
|
||||
super.onDataLoaded(d, more);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefresh(){
|
||||
for(APIRequest<?> req:relationshipsRequests){
|
||||
req.cancel();
|
||||
}
|
||||
relationshipsRequests.clear();
|
||||
super.onRefresh();
|
||||
}
|
||||
|
||||
protected void loadRelationships(List<AccountItem> accounts){
|
||||
Set<String> ids=accounts.stream().map(ai->ai.account.id).collect(Collectors.toSet());
|
||||
GetAccountRelationships req=new GetAccountRelationships(ids);
|
||||
relationshipsRequests.add(req);
|
||||
req.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(List<Relationship> result){
|
||||
relationshipsRequests.remove(req);
|
||||
for(Relationship rel:result){
|
||||
relationships.put(rel.id, rel);
|
||||
}
|
||||
if(list==null)
|
||||
return;
|
||||
for(int i=0;i<list.getChildCount();i++){
|
||||
if(list.getChildViewHolder(list.getChildAt(i)) instanceof AccountViewHolder avh){
|
||||
avh.bindRelationship();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
relationshipsRequests.remove(req);
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RecyclerView.Adapter getAdapter(){
|
||||
return new AccountsAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
// list.setPadding(0, V.dp(16), 0, V.dp(16));
|
||||
list.setClipToPadding(false);
|
||||
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 72, 16));
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig){
|
||||
super.onConfigurationChanged(newConfig);
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
protected void updateToolbar(){
|
||||
Toolbar toolbar=getToolbar();
|
||||
if(toolbar!=null && toolbar.getNavigationIcon()!=null){
|
||||
toolbar.setNavigationContentDescription(R.string.back);
|
||||
if(hasSubtitle()){
|
||||
toolbar.setTitleTextAppearance(getActivity(), R.style.m3_title_medium);
|
||||
toolbar.setSubtitleTextAppearance(getActivity(), R.style.m3_body_medium);
|
||||
int color=UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary);
|
||||
toolbar.setTitleTextColor(color);
|
||||
toolbar.setSubtitleTextColor(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean hasSubtitle(){
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onApplyWindowInsets(WindowInsets insets){
|
||||
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
|
||||
list.setPadding(0, V.dp(16), 0, V.dp(16)+insets.getSystemWindowInsetBottom());
|
||||
insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom());
|
||||
}else{
|
||||
list.setPadding(0, V.dp(16), 0, V.dp(16));
|
||||
}
|
||||
super.onApplyWindowInsets(insets);
|
||||
}
|
||||
|
||||
protected class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
|
||||
public AccountsAdapter(){
|
||||
super(imgLoader);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
return new AccountViewHolder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(AccountViewHolder holder, int position){
|
||||
holder.bind(data.get(position));
|
||||
super.onBindViewHolder(holder, position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount(){
|
||||
return data.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getImageCountForItem(int position){
|
||||
return data.get(position).emojiHelper.getImageCount()+1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImageLoaderRequest getImageRequest(int position, int image){
|
||||
AccountItem item=data.get(position);
|
||||
return image==0 ? item.avaRequest : item.emojiHelper.getImageRequest(image-1);
|
||||
}
|
||||
}
|
||||
|
||||
protected class AccountViewHolder extends BindableViewHolder<AccountItem> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable, UsableRecyclerView.LongClickable{
|
||||
private final TextView name, username;
|
||||
private final ImageView avatar;
|
||||
private final Button button;
|
||||
private final PopupMenu contextMenu;
|
||||
private final View menuAnchor;
|
||||
|
||||
public AccountViewHolder(){
|
||||
super(getActivity(), R.layout.item_account_list, list);
|
||||
name=findViewById(R.id.name);
|
||||
username=findViewById(R.id.username);
|
||||
avatar=findViewById(R.id.avatar);
|
||||
button=findViewById(R.id.button);
|
||||
menuAnchor=findViewById(R.id.menu_anchor);
|
||||
|
||||
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
|
||||
avatar.setClipToOutline(true);
|
||||
|
||||
button.setOnClickListener(this::onButtonClick);
|
||||
|
||||
contextMenu=new PopupMenu(getActivity(), menuAnchor);
|
||||
contextMenu.inflate(R.menu.profile);
|
||||
contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(AccountItem item){
|
||||
name.setText(item.parsedName);
|
||||
username.setText("@"+item.account.acct);
|
||||
bindRelationship();
|
||||
}
|
||||
|
||||
public void bindRelationship(){
|
||||
Relationship rel=relationships.get(item.account.id);
|
||||
if(rel==null || AccountSessionManager.getInstance().isSelf(accountID, item.account)){
|
||||
button.setVisibility(View.GONE);
|
||||
}else{
|
||||
button.setVisibility(View.VISIBLE);
|
||||
UiUtils.setRelationshipToActionButton(rel, button);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setImage(int index, Drawable image){
|
||||
if(index==0){
|
||||
avatar.setImageDrawable(image);
|
||||
}else{
|
||||
item.emojiHelper.setImageDrawable(index-1, image);
|
||||
name.invalidate();
|
||||
}
|
||||
|
||||
if(image instanceof Animatable a && !a.isRunning())
|
||||
a.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearImage(int index){
|
||||
setImage(index, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("profileAccount", Parcels.wrap(item.account));
|
||||
Nav.go(getActivity(), ProfileFragment.class, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(){
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(float x, float y){
|
||||
Relationship relationship=relationships.get(item.account.id);
|
||||
if(relationship==null)
|
||||
return false;
|
||||
Menu menu=contextMenu.getMenu();
|
||||
Account account=item.account;
|
||||
|
||||
menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getDisplayUsername()));
|
||||
menu.findItem(R.id.mute).setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
|
||||
menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername()));
|
||||
menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.getDisplayUsername()));
|
||||
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
|
||||
if(relationship.following){
|
||||
hideBoosts.setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
|
||||
hideBoosts.setVisible(true);
|
||||
}else{
|
||||
hideBoosts.setVisible(false);
|
||||
}
|
||||
MenuItem blockDomain=menu.findItem(R.id.block_domain);
|
||||
if(!account.isLocal()){
|
||||
blockDomain.setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
|
||||
blockDomain.setVisible(true);
|
||||
}else{
|
||||
blockDomain.setVisible(false);
|
||||
}
|
||||
|
||||
menuAnchor.setTranslationX(x);
|
||||
menuAnchor.setTranslationY(y);
|
||||
contextMenu.show();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void onButtonClick(View v){
|
||||
ProgressDialog progress=new ProgressDialog(getActivity());
|
||||
progress.setMessage(getString(R.string.loading));
|
||||
progress.setCancelable(false);
|
||||
UiUtils.performAccountAction(getActivity(), item.account, accountID, relationships.get(item.account.id), button, progressShown->{
|
||||
itemView.setHasTransientState(progressShown);
|
||||
if(progressShown)
|
||||
progress.show();
|
||||
else
|
||||
progress.dismiss();
|
||||
}, result->{
|
||||
relationships.put(item.account.id, result);
|
||||
bindRelationship();
|
||||
});
|
||||
}
|
||||
|
||||
private boolean onContextMenuItemSelected(MenuItem item){
|
||||
Relationship relationship=relationships.get(this.item.account.id);
|
||||
if(relationship==null)
|
||||
return false;
|
||||
Account account=this.item.account;
|
||||
|
||||
int id=item.getItemId();
|
||||
if(id==R.id.share){
|
||||
Intent intent=new Intent(Intent.ACTION_SEND);
|
||||
intent.setType("text/plain");
|
||||
intent.putExtra(Intent.EXTRA_TEXT, account.url);
|
||||
startActivity(Intent.createChooser(intent, item.getTitle()));
|
||||
}else if(id==R.id.mute){
|
||||
UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship);
|
||||
}else if(id==R.id.block){
|
||||
UiUtils.confirmToggleBlockUser(getActivity(), accountID, account, relationship.blocking, this::updateRelationship);
|
||||
}else if(id==R.id.report){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("reportAccount", Parcels.wrap(account));
|
||||
Nav.go(getActivity(), ReportReasonChoiceFragment.class, args);
|
||||
}else if(id==R.id.open_in_browser){
|
||||
UiUtils.launchWebBrowser(getActivity(), account.url);
|
||||
}else if(id==R.id.block_domain){
|
||||
UiUtils.confirmToggleBlockDomain(getActivity(), accountID, account.getDomain(), relationship.domainBlocking, ()->{
|
||||
relationship.domainBlocking=!relationship.domainBlocking;
|
||||
bindRelationship();
|
||||
});
|
||||
}else if(id==R.id.hide_boosts){
|
||||
new SetAccountFollowed(account.id, true, !relationship.showingReblogs)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Relationship result){
|
||||
relationships.put(AccountViewHolder.this.item.account.id, result);
|
||||
bindRelationship();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
error.showToast(getActivity());
|
||||
}
|
||||
})
|
||||
.wrapProgress(getActivity(), R.string.loading, false)
|
||||
.exec(accountID);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void updateRelationship(Relationship r){
|
||||
relationships.put(item.account.id, r);
|
||||
bindRelationship();
|
||||
}
|
||||
}
|
||||
|
||||
protected static class AccountItem{
|
||||
public final Account account;
|
||||
public final ImageLoaderRequest avaRequest;
|
||||
public final CustomEmojiHelper emojiHelper;
|
||||
public final CharSequence parsedName;
|
||||
|
||||
public AccountItem(Account account){
|
||||
this.account=account;
|
||||
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(50), V.dp(50));
|
||||
emojiHelper=new CustomEmojiHelper();
|
||||
emojiHelper.setText(parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.joinmastodon.android.fragments.account_list;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountFollowers;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
|
||||
public class FollowerListFragment extends AccountRelatedAccountListFragment{
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setSubtitle(getResources().getQuantityString(R.plurals.x_followers, account.followersCount, account.followersCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
|
||||
return new GetAccountFollowers(account.id, maxID, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.joinmastodon.android.fragments.account_list;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountFollowing;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
|
||||
public class FollowingListFragment extends AccountRelatedAccountListFragment{
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setSubtitle(getResources().getQuantityString(R.plurals.x_following, account.followingCount, account.followingCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
|
||||
return new GetAccountFollowing(account.id, maxID, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.joinmastodon.android.fragments.account_list;
|
||||
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.HeaderPaginationList;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
|
||||
public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{
|
||||
private String nextMaxID;
|
||||
|
||||
public abstract HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count);
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
currentRequest=onCreateRequest(offset==0 ? null : nextMaxID, count)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(HeaderPaginationList<Account> result){
|
||||
if(result.nextPageUri!=null)
|
||||
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
|
||||
else
|
||||
nextMaxID=null;
|
||||
onDataLoaded(result.stream().map(AccountItem::new).collect(Collectors.toList()), nextMaxID!=null);
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume(){
|
||||
super.onResume();
|
||||
if(!loaded && !dataLoading)
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.joinmastodon.android.fragments.account_list;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.api.requests.statuses.GetStatusFavorites;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
|
||||
public class StatusFavoritesListFragment extends StatusRelatedAccountListFragment{
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setTitle(getResources().getQuantityString(R.plurals.x_favorites, status.favouritesCount, status.favouritesCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
|
||||
return new GetStatusFavorites(status.id, maxID, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.joinmastodon.android.fragments.account_list;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.api.requests.statuses.GetStatusReblogs;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
|
||||
public class StatusReblogsListFragment extends StatusRelatedAccountListFragment{
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setTitle(getResources().getQuantityString(R.plurals.x_reblogs, status.reblogsCount, status.reblogsCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
|
||||
return new GetStatusReblogs(status.id, maxID, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.joinmastodon.android.fragments.account_list;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment{
|
||||
protected Status status;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
status=Parcels.unwrap(getArguments().getParcelable("status"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean hasSubtitle(){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,18 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
}
|
||||
});
|
||||
tabLayoutMediator.attach();
|
||||
tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener(){
|
||||
@Override
|
||||
public void onTabSelected(TabLayout.Tab tab){}
|
||||
|
||||
@Override
|
||||
public void onTabUnselected(TabLayout.Tab tab){}
|
||||
|
||||
@Override
|
||||
public void onTabReselected(TabLayout.Tab tab){
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
searchEdit=view.findViewById(R.id.search_edit);
|
||||
searchEdit.setOnFocusChangeListener(this::onSearchEditFocusChanged);
|
||||
|
||||
@@ -5,23 +5,29 @@ import android.view.View;
|
||||
|
||||
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
|
||||
import org.joinmastodon.android.fragments.StatusListFragment;
|
||||
import org.joinmastodon.android.model.Filter;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
|
||||
import org.joinmastodon.android.utils.StatusFilterPredicate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
|
||||
public class LocalTimelineFragment extends StatusListFragment{
|
||||
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE);
|
||||
private String maxID;
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : getMaxID(), count)
|
||||
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
onDataLoaded(result, !result.isEmpty());
|
||||
if(!result.isEmpty())
|
||||
maxID=result.get(result.size()-1).id;
|
||||
onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty());
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
|
||||
@@ -117,6 +117,8 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
|
||||
protected void doLoadData(int offset, int count){
|
||||
if(isInRecentMode()){
|
||||
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().getRecentSearches(sr->{
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
unfilteredResults=sr;
|
||||
prevDisplayItems=new ArrayList<>(displayItems);
|
||||
onDataLoaded(sr, false);
|
||||
@@ -203,7 +205,7 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
|
||||
|
||||
@Override
|
||||
public void onTabReselected(TabLayout.Tab tab){
|
||||
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.view.WindowInsets;
|
||||
import android.widget.Button;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
|
||||
@@ -136,6 +137,13 @@ public class AccountActivationFragment extends AppKitFragment{
|
||||
}
|
||||
|
||||
private void tryGetAccount(){
|
||||
if(AccountSessionManager.getInstance().tryGetAccount(accountID)==null){
|
||||
uiHandler.removeCallbacks(pollRunnable);
|
||||
getActivity().finish();
|
||||
Intent intent=new Intent(getActivity(), MainActivity.class);
|
||||
startActivity(intent);
|
||||
return;
|
||||
}
|
||||
currentRequest=new GetOwnAccount()
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments.onboarding;
|
||||
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.LocaleList;
|
||||
@@ -349,7 +350,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
currentSearchQuery=searchEdit.getText().toString().toLowerCase();
|
||||
updateFilteredList();
|
||||
searchEdit.removeCallbacks(searchDebouncer);
|
||||
Instance instance=instancesCache.get(currentSearchQuery);
|
||||
Instance instance=instancesCache.get(normalizeInstanceDomain(currentSearchQuery));
|
||||
if(instance==null){
|
||||
showProgressDialog();
|
||||
loadInstanceInfo(currentSearchQuery);
|
||||
@@ -412,15 +413,27 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||
instanceProgressDialog.show();
|
||||
}
|
||||
|
||||
private void loadInstanceInfo(String _domain){
|
||||
private String normalizeInstanceDomain(String _domain){
|
||||
if(TextUtils.isEmpty(_domain))
|
||||
return;
|
||||
return null;
|
||||
if(_domain.contains(":")){
|
||||
try{
|
||||
_domain=Uri.parse(_domain).getAuthority();
|
||||
}catch(Exception ignore){}
|
||||
if(TextUtils.isEmpty(_domain))
|
||||
return null;
|
||||
}
|
||||
String domain;
|
||||
try{
|
||||
domain=IDN.toASCII(_domain);
|
||||
}catch(IllegalArgumentException x){
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
private void loadInstanceInfo(String _domain){
|
||||
String domain=normalizeInstanceDomain(_domain);
|
||||
Instance cachedInstance=instancesCache.get(domain);
|
||||
if(cachedInstance!=null){
|
||||
for(CatalogInstance ci:filteredData){
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.widget.TextView;
|
||||
|
||||
import com.squareup.otto.Subscribe;
|
||||
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
|
||||
import org.joinmastodon.android.events.FinishReportFragmentsEvent;
|
||||
@@ -60,6 +61,13 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
|
||||
setRetainInstance(true);
|
||||
setListLayoutId(R.layout.fragment_content_report_posts);
|
||||
setLayout(R.layout.fragment_report_posts);
|
||||
E.register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy(){
|
||||
E.unregister(this);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -5,7 +5,9 @@ import android.os.Bundle;
|
||||
import com.squareup.otto.Subscribe;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.events.FinishReportFragmentsEvent;
|
||||
import org.joinmastodon.android.model.Instance;
|
||||
import org.joinmastodon.android.model.ReportReason;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
@@ -21,7 +23,10 @@ public class ReportReasonChoiceFragment extends BaseReportChoiceFragment{
|
||||
protected void populateItems(){
|
||||
items.add(new Item(getString(R.string.report_reason_personal), getString(R.string.report_reason_personal_subtitle), ReportReason.PERSONAL.name()));
|
||||
items.add(new Item(getString(R.string.report_reason_spam), getString(R.string.report_reason_spam_subtitle), ReportReason.SPAM.name()));
|
||||
items.add(new Item(getString(R.string.report_reason_violation), getString(R.string.report_reason_violation_subtitle), ReportReason.VIOLATION.name()));
|
||||
Instance inst=AccountSessionManager.getInstance().getInstanceInfo(AccountSessionManager.getInstance().getAccount(accountID).domain);
|
||||
if(inst!=null && inst.rules!=null && !inst.rules.isEmpty()){
|
||||
items.add(new Item(getString(R.string.report_reason_violation), getString(R.string.report_reason_violation_subtitle), ReportReason.VIOLATION.name()));
|
||||
}
|
||||
items.add(new Item(getString(R.string.report_reason_other), getString(R.string.report_reason_other_subtitle), ReportReason.OTHER.name()));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.joinmastodon.android.model;
|
||||
|
||||
public class CacheablePaginatedResponse<T> extends PaginatedResponse<T>{
|
||||
private final boolean fromCache;
|
||||
|
||||
public CacheablePaginatedResponse(T items, String maxID, boolean fromCache){
|
||||
super(items, maxID);
|
||||
this.fromCache=fromCache;
|
||||
}
|
||||
|
||||
public boolean isFromCache(){
|
||||
return fromCache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.joinmastodon.android.model;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class HeaderPaginationList<T> extends ArrayList<T>{
|
||||
public Uri nextPageUri, prevPageUri;
|
||||
|
||||
public HeaderPaginationList(int initialCapacity){
|
||||
super(initialCapacity);
|
||||
}
|
||||
|
||||
public HeaderPaginationList(){
|
||||
super();
|
||||
}
|
||||
|
||||
public HeaderPaginationList(@NonNull Collection<? extends T> c){
|
||||
super(c);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ public class Status extends BaseModel implements DisplayItemsParent{
|
||||
public boolean pinned;
|
||||
|
||||
public transient boolean spoilerRevealed;
|
||||
public transient boolean hasGapAfter;
|
||||
|
||||
@Override
|
||||
public void postprocess() throws ObjectValidationException{
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package org.joinmastodon.android.ui;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.drawable.Animatable;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowInsets;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.PopupMenu;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.SplashFragment;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
|
||||
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
|
||||
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
|
||||
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.utils.MergeRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.BottomSheet;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
|
||||
public class AccountSwitcherSheet extends BottomSheet{
|
||||
private final Activity activity;
|
||||
private UsableRecyclerView list;
|
||||
private List<WrappedAccount> accounts;
|
||||
private ListImageLoaderWrapper imgLoader;
|
||||
|
||||
public AccountSwitcherSheet(@NonNull Activity activity){
|
||||
super(activity);
|
||||
this.activity=activity;
|
||||
|
||||
accounts=AccountSessionManager.getInstance().getLoggedInAccounts().stream().map(WrappedAccount::new).collect(Collectors.toList());
|
||||
|
||||
list=new UsableRecyclerView(activity);
|
||||
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
|
||||
list.setClipToPadding(false);
|
||||
list.setLayoutManager(new LinearLayoutManager(activity));
|
||||
|
||||
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
|
||||
View handle=new View(activity);
|
||||
handle.setBackgroundResource(R.drawable.bg_bottom_sheet_handle);
|
||||
adapter.addAdapter(new SingleViewRecyclerAdapter(handle));
|
||||
adapter.addAdapter(new AccountsAdapter());
|
||||
AccountViewHolder holder=new AccountViewHolder();
|
||||
holder.more.setVisibility(View.GONE);
|
||||
holder.currentIcon.setVisibility(View.GONE);
|
||||
holder.name.setText(R.string.add_account);
|
||||
holder.avatar.setScaleType(ImageView.ScaleType.CENTER);
|
||||
holder.avatar.setImageResource(R.drawable.ic_fluent_add_circle_24_filled);
|
||||
holder.avatar.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(activity, android.R.attr.textColorPrimary)));
|
||||
adapter.addAdapter(new ClickableSingleViewRecyclerAdapter(holder.itemView, ()->{
|
||||
Nav.go(activity, SplashFragment.class, null);
|
||||
dismiss();
|
||||
}));
|
||||
|
||||
list.setAdapter(adapter);
|
||||
DividerItemDecoration divider=new DividerItemDecoration(activity, R.attr.colorPollVoted, .5f, 72, 16, DividerItemDecoration.NOT_FIRST);
|
||||
divider.setDrawBelowLastItem(true);
|
||||
list.addItemDecoration(divider);
|
||||
|
||||
FrameLayout content=new FrameLayout(activity);
|
||||
content.setBackgroundResource(R.drawable.bg_bottom_sheet);
|
||||
content.addView(list);
|
||||
setContentView(content);
|
||||
setNavigationBarBackground(new ColorDrawable(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground)), !UiUtils.isDarkTheme());
|
||||
}
|
||||
|
||||
private void confirmLogOut(String accountID){
|
||||
new M3AlertDialogBuilder(activity)
|
||||
.setTitle(R.string.log_out)
|
||||
.setMessage(R.string.confirm_log_out)
|
||||
.setPositiveButton(R.string.log_out, (dialog, which) -> logOut(accountID))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void logOut(String accountID){
|
||||
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
|
||||
new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Object result){
|
||||
onLoggedOut(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
onLoggedOut(accountID);
|
||||
}
|
||||
})
|
||||
.wrapProgress(activity, R.string.loading, false)
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
private void onLoggedOut(String accountID){
|
||||
AccountSessionManager.getInstance().removeAccount(accountID);
|
||||
dismiss();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onWindowInsetsUpdated(WindowInsets insets){
|
||||
if(Build.VERSION.SDK_INT>=29){
|
||||
int tappableBottom=insets.getTappableElementInsets().bottom;
|
||||
int insetBottom=insets.getSystemWindowInsetBottom();
|
||||
if(tappableBottom==0 && insetBottom>0){
|
||||
list.setPadding(0, 0, 0, V.dp(48)-insetBottom);
|
||||
}else{
|
||||
list.setPadding(0, 0, 0, V.dp(24));
|
||||
}
|
||||
}else{
|
||||
list.setPadding(0, 0, 0, V.dp(24));
|
||||
}
|
||||
}
|
||||
|
||||
private class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
|
||||
public AccountsAdapter(){
|
||||
super(imgLoader);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
return new AccountViewHolder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount(){
|
||||
return accounts.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(AccountViewHolder holder, int position){
|
||||
holder.bind(accounts.get(position).session);
|
||||
super.onBindViewHolder(holder, position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getImageCountForItem(int position){
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImageLoaderRequest getImageRequest(int position, int image){
|
||||
return accounts.get(position).req;
|
||||
}
|
||||
}
|
||||
|
||||
private class AccountViewHolder extends BindableViewHolder<AccountSession> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
|
||||
private final TextView name;
|
||||
private final ImageView avatar;
|
||||
private final ImageButton more;
|
||||
private final View currentIcon;
|
||||
private final PopupMenu menu;
|
||||
|
||||
public AccountViewHolder(){
|
||||
super(activity, R.layout.item_account_switcher, list);
|
||||
name=findViewById(R.id.name);
|
||||
avatar=findViewById(R.id.avatar);
|
||||
more=findViewById(R.id.more);
|
||||
currentIcon=findViewById(R.id.current);
|
||||
|
||||
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
|
||||
avatar.setClipToOutline(true);
|
||||
|
||||
menu=new PopupMenu(activity, more);
|
||||
menu.inflate(R.menu.account_switcher);
|
||||
menu.setOnMenuItemClickListener(item1 -> {
|
||||
confirmLogOut(item.getID());
|
||||
return true;
|
||||
});
|
||||
more.setOnClickListener(v->menu.show());
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
@Override
|
||||
public void onBind(AccountSession item){
|
||||
name.setText("@"+item.self.username+"@"+item.domain);
|
||||
if(AccountSessionManager.getInstance().getLastActiveAccountID().equals(item.getID())){
|
||||
more.setVisibility(View.GONE);
|
||||
currentIcon.setVisibility(View.VISIBLE);
|
||||
}else{
|
||||
more.setVisibility(View.VISIBLE);
|
||||
currentIcon.setVisibility(View.GONE);
|
||||
}
|
||||
menu.getMenu().findItem(R.id.log_out).setTitle(activity.getString(R.string.log_out_account, "@"+item.self.username));
|
||||
UiUtils.enablePopupMenuIcons(activity, menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setImage(int index, Drawable image){
|
||||
avatar.setImageDrawable(image);
|
||||
if(image instanceof Animatable a)
|
||||
a.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearImage(int index){
|
||||
setImage(index, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
AccountSessionManager.getInstance().setLastActiveAccountID(item.getID());
|
||||
activity.finish();
|
||||
activity.startActivity(new Intent(activity, MainActivity.class));
|
||||
}
|
||||
}
|
||||
|
||||
private static class WrappedAccount{
|
||||
public final AccountSession session;
|
||||
public final ImageLoaderRequest req;
|
||||
|
||||
public WrappedAccount(AccountSession session){
|
||||
this.session=session;
|
||||
req=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? session.self.avatar : session.self.avatarStatic, V.dp(50), V.dp(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.joinmastodon.android.ui;
|
||||
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
|
||||
public class ClickableSingleViewRecyclerAdapter extends SingleViewRecyclerAdapter{
|
||||
private final Runnable onClick;
|
||||
|
||||
public ClickableSingleViewRecyclerAdapter(View view, Runnable onClick){
|
||||
super(view);
|
||||
this.onClick=onClick;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
return new ClickableViewViewHolder(view);
|
||||
}
|
||||
|
||||
public class ClickableViewViewHolder extends ViewViewHolder implements UsableRecyclerView.Clickable{
|
||||
public ClickableViewViewHolder(@NonNull View itemView){
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
onClick.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ public class ComposeAutocompleteViewController{
|
||||
.map(WrappedEmoji::new)
|
||||
.collect(Collectors.toList());
|
||||
UiUtils.updateList(oldList, emojis, list, emojisAdapter, (e1, e2)->e1.emoji.shortcode.equals(e2.emoji.shortcode));
|
||||
imgLoader.updateImages();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +187,7 @@ public class ComposeAutocompleteViewController{
|
||||
List<WrappedAccount> oldList=users;
|
||||
users=result.accounts.stream().map(WrappedAccount::new).collect(Collectors.toList());
|
||||
UiUtils.updateList(oldList, users, list, usersAdapter, (a1, a2)->a1.account.id.equals(a2.account.id));
|
||||
imgLoader.updateImages();
|
||||
if(listIsHidden){
|
||||
listIsHidden=false;
|
||||
V.setVisibilityAnimated(list, View.VISIBLE);
|
||||
@@ -210,6 +212,7 @@ public class ComposeAutocompleteViewController{
|
||||
List<Hashtag> oldList=hashtags;
|
||||
hashtags=result.hashtags;
|
||||
UiUtils.updateList(oldList, hashtags, list, hashtagsAdapter, (t1, t2)->t1.name.equals(t2.name));
|
||||
imgLoader.updateImages();
|
||||
if(listIsHidden){
|
||||
listIsHidden=false;
|
||||
V.setVisibilityAnimated(list, View.VISIBLE);
|
||||
|
||||
@@ -18,6 +18,7 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{
|
||||
private Paint paint=new Paint();
|
||||
private int paddingStart, paddingEnd;
|
||||
private Predicate<RecyclerView.ViewHolder> drawDividerPredicate;
|
||||
private boolean drawBelowLastItem;
|
||||
|
||||
public static final Predicate<RecyclerView.ViewHolder> NOT_FIRST=vh->vh.getAbsoluteAdapterPosition()>0;
|
||||
|
||||
@@ -34,6 +35,10 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{
|
||||
this.drawDividerPredicate=drawDividerPredicate;
|
||||
}
|
||||
|
||||
public void setDrawBelowLastItem(boolean drawBelowLastItem){
|
||||
this.drawBelowLastItem=drawBelowLastItem;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
|
||||
boolean isRTL=parent.getLayoutDirection()==View.LAYOUT_DIRECTION_RTL;
|
||||
@@ -43,7 +48,7 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{
|
||||
for(int i=0;i<parent.getChildCount();i++){
|
||||
View child=parent.getChildAt(i);
|
||||
int pos=parent.getChildAdapterPosition(child);
|
||||
if(pos<totalItems-1 && (drawDividerPredicate==null || drawDividerPredicate.test(parent.getChildViewHolder(child)))){
|
||||
if((drawBelowLastItem || pos<totalItems-1) && (drawDividerPredicate==null || drawDividerPredicate.test(parent.getChildViewHolder(child)))){
|
||||
float y=Math.round(child.getY()+child.getHeight());
|
||||
y-=(y-paint.getStrokeWidth()/2f)%1f; // Make sure the line aligns with the pixel grid
|
||||
paint.setAlpha(Math.round(255f*child.getAlpha()));
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.joinmastodon.android.ui;
|
||||
|
||||
import android.app.Fragment;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.View;
|
||||
|
||||
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class SingleImagePhotoViewerListener implements PhotoViewer.Listener{
|
||||
private final View sourceView, transformView;
|
||||
private final int[] cornerRadius;
|
||||
private final Runnable onDismissed;
|
||||
private final Fragment parentFragment;
|
||||
private final Supplier<Drawable> currentDrawableSupplier;
|
||||
private final Runnable onStart, onEnd;
|
||||
|
||||
private float origAlpha;
|
||||
|
||||
public SingleImagePhotoViewerListener(View sourceView, View transformView, int[] cornerRadius, Fragment parentFragment, Runnable onDismissed, Supplier<Drawable> currentDrawableSupplier, Runnable onStart, Runnable onEnd){
|
||||
this.sourceView=sourceView;
|
||||
this.transformView=transformView;
|
||||
this.cornerRadius=cornerRadius;
|
||||
this.onDismissed=onDismissed;
|
||||
this.parentFragment=parentFragment;
|
||||
this.currentDrawableSupplier=currentDrawableSupplier;
|
||||
this.onStart=onStart;
|
||||
this.onEnd=onEnd;
|
||||
if(cornerRadius!=null && cornerRadius.length!=4)
|
||||
throw new IllegalArgumentException("Corner radius must be null or have length of 4");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPhotoViewVisibility(int index, boolean visible){
|
||||
transformView.setVisibility(visible ? View.VISIBLE : View.INVISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){
|
||||
int[] loc={0, 0};
|
||||
sourceView.getLocationOnScreen(loc);
|
||||
outRect.set(loc[0], loc[1], loc[0]+sourceView.getWidth(), loc[1]+sourceView.getHeight());
|
||||
if(cornerRadius!=null)
|
||||
System.arraycopy(cornerRadius, 0, outCornerRadius, 0, 4);
|
||||
transformView.setTranslationZ(1);
|
||||
if(onStart!=null)
|
||||
onStart.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTransitioningViewTransform(float translateX, float translateY, float scale){
|
||||
transformView.setTranslationX(translateX);
|
||||
transformView.setTranslationY(translateY);
|
||||
transformView.setScaleX(scale);
|
||||
transformView.setScaleY(scale);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endPhotoViewTransition(){
|
||||
setTransitioningViewTransform(0f, 0f, 1f);
|
||||
transformView.setTranslationZ(0);
|
||||
if(onEnd!=null)
|
||||
onEnd.run();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Drawable getPhotoViewCurrentDrawable(int index){
|
||||
return currentDrawableSupplier.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void photoViewerDismissed(){
|
||||
onDismissed.run();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissions(String[] permissions){
|
||||
parentFragment.requestPermissions(permissions, PhotoViewer.PERMISSION_REQUEST);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,9 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
|
||||
public void onBind(AudioStatusDisplayItem item){
|
||||
int seconds=(int)item.attachment.getDuration();
|
||||
String duration=formatDuration(seconds);
|
||||
time.getLayoutParams().width=(int)Math.ceil(time.getPaint().measureText("-"+duration));
|
||||
// Some fonts (not Roboto) have different-width digits. 0 is supposedly the widest.
|
||||
time.getLayoutParams().width=(int)Math.ceil(Math.max(time.getPaint().measureText("-"+duration),
|
||||
time.getPaint().measureText("-"+duration.replaceAll("\\d", "0"))));
|
||||
time.setText(duration);
|
||||
AudioPlayerService service=AudioPlayerService.getInstance();
|
||||
if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.joinmastodon.android.ui.displayitems;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.text.style.TypefaceSpan;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.fragments.BaseStatusListFragment;
|
||||
import org.joinmastodon.android.fragments.account_list.StatusFavoritesListFragment;
|
||||
import org.joinmastodon.android.fragments.account_list.StatusReblogsListFragment;
|
||||
import org.joinmastodon.android.fragments.account_list.StatusRelatedAccountListFragment;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.FormatStyle;
|
||||
import java.util.Locale;
|
||||
|
||||
import androidx.annotation.PluralsRes;
|
||||
import me.grishka.appkit.Nav;
|
||||
|
||||
public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
|
||||
public final Status status;
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
|
||||
|
||||
public ExtendedFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status){
|
||||
super(parentID, parentFragment);
|
||||
this.status=status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type getType(){
|
||||
return Type.EXTENDED_FOOTER;
|
||||
}
|
||||
|
||||
public static class Holder extends StatusDisplayItem.Holder<ExtendedFooterStatusDisplayItem>{
|
||||
private final TextView reblogs, favorites, time;
|
||||
private final View buttonsView;
|
||||
|
||||
public Holder(Context context, ViewGroup parent){
|
||||
super(context, R.layout.display_item_extended_footer, parent);
|
||||
reblogs=findViewById(R.id.reblogs);
|
||||
favorites=findViewById(R.id.favorites);
|
||||
time=findViewById(R.id.timestamp);
|
||||
buttonsView=findViewById(R.id.button_bar);
|
||||
|
||||
reblogs.setOnClickListener(v->startAccountListFragment(StatusReblogsListFragment.class));
|
||||
favorites.setOnClickListener(v->startAccountListFragment(StatusFavoritesListFragment.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(ExtendedFooterStatusDisplayItem item){
|
||||
Status s=item.status;
|
||||
if(s.favouritesCount>0){
|
||||
favorites.setVisibility(View.VISIBLE);
|
||||
favorites.setText(getFormattedPlural(R.plurals.x_favorites, s.favouritesCount));
|
||||
}else{
|
||||
favorites.setVisibility(View.GONE);
|
||||
}
|
||||
if(s.reblogsCount>0){
|
||||
reblogs.setVisibility(View.VISIBLE);
|
||||
reblogs.setText(getFormattedPlural(R.plurals.x_reblogs, s.reblogsCount));
|
||||
}else{
|
||||
reblogs.setVisibility(View.GONE);
|
||||
}
|
||||
if(s.favouritesCount==0 && s.reblogsCount==0){
|
||||
buttonsView.setVisibility(View.GONE);
|
||||
}else{
|
||||
buttonsView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
String timeStr=TIME_FORMATTER.format(item.status.createdAt.atZone(ZoneId.systemDefault()));
|
||||
if(item.status.application!=null && !TextUtils.isEmpty(item.status.application.name)){
|
||||
time.setText(item.parentFragment.getString(R.string.timestamp_via_app, timeStr, item.status.application.name));
|
||||
}else{
|
||||
time.setText(timeStr);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled(){
|
||||
return false;
|
||||
}
|
||||
|
||||
private SpannableStringBuilder getFormattedPlural(@PluralsRes int res, int quantity){
|
||||
String str=item.parentFragment.getResources().getQuantityString(res, quantity, quantity);
|
||||
String formattedNumber=String.format(Locale.getDefault(), "%,d", quantity);
|
||||
int index=str.indexOf(formattedNumber);
|
||||
SpannableStringBuilder ssb=new SpannableStringBuilder(str);
|
||||
if(index>=0){
|
||||
ssb.setSpan(new TypefaceSpan("sans-serif-medium"), index, index+formattedNumber.length(), 0);
|
||||
ssb.setSpan(new ForegroundColorSpan(UiUtils.getThemeColor(item.parentFragment.getActivity(), android.R.attr.textColorPrimary)), index, index+formattedNumber.length(), 0);
|
||||
}
|
||||
return ssb;
|
||||
}
|
||||
|
||||
private void startAccountListFragment(Class<? extends StatusRelatedAccountListFragment> cls){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", item.parentFragment.getAccountID());
|
||||
args.putParcelable("status", Parcels.wrap(item.status));
|
||||
Nav.go(item.parentFragment.getActivity(), cls, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import me.grishka.appkit.utils.V;
|
||||
public class FooterStatusDisplayItem extends StatusDisplayItem{
|
||||
public final Status status;
|
||||
private final String accountID;
|
||||
public boolean hideCounts;
|
||||
|
||||
public FooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status, String accountID){
|
||||
super(parentID, parentFragment);
|
||||
@@ -91,7 +92,7 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
|
||||
}
|
||||
|
||||
private void bindButton(TextView btn, int count){
|
||||
if(count>0){
|
||||
if(count>0 && !item.hideCounts){
|
||||
btn.setText(DecimalFormat.getIntegerInstance().format(count));
|
||||
btn.setCompoundDrawablePadding(V.dp(8));
|
||||
}else{
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.joinmastodon.android.ui.displayitems;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.fragments.BaseStatusListFragment;
|
||||
import org.joinmastodon.android.ui.drawables.SawtoothTearDrawable;
|
||||
|
||||
// Mind the gap!
|
||||
public class GapStatusDisplayItem extends StatusDisplayItem{
|
||||
public boolean loading;
|
||||
|
||||
public GapStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment){
|
||||
super(parentID, parentFragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type getType(){
|
||||
return Type.GAP;
|
||||
}
|
||||
|
||||
public static class Holder extends StatusDisplayItem.Holder<GapStatusDisplayItem>{
|
||||
public final ProgressBar progress;
|
||||
public final TextView text;
|
||||
|
||||
public Holder(Context context, ViewGroup parent){
|
||||
super(context, R.layout.display_item_gap, parent);
|
||||
progress=findViewById(R.id.progress);
|
||||
text=findViewById(R.id.text);
|
||||
itemView.setForeground(new SawtoothTearDrawable(context));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(GapStatusDisplayItem item){
|
||||
text.setVisibility(item.loading ? View.GONE : View.VISIBLE);
|
||||
progress.setVisibility(item.loading ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
item.parentFragment.onGapClick(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
|
||||
Menu menu=optionsMenu.getMenu();
|
||||
boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account);
|
||||
menu.findItem(R.id.delete).setVisible(item.status!=null && isOwnPost);
|
||||
menu.findItem(R.id.open_in_browser).setVisible(item.status!=null);
|
||||
MenuItem blockDomain=menu.findItem(R.id.block_domain);
|
||||
MenuItem mute=menu.findItem(R.id.mute);
|
||||
MenuItem block=menu.findItem(R.id.block);
|
||||
|
||||
@@ -63,6 +63,8 @@ public abstract class StatusDisplayItem{
|
||||
case ACCOUNT_CARD -> new AccountCardStatusDisplayItem.Holder(activity, parent);
|
||||
case ACCOUNT -> new AccountStatusDisplayItem.Holder(activity, parent);
|
||||
case HASHTAG -> new HashtagStatusDisplayItem.Holder(activity, parent);
|
||||
case GAP -> new GapStatusDisplayItem.Holder(activity, parent);
|
||||
case EXTENDED_FOOTER -> new ExtendedFooterStatusDisplayItem.Holder(activity, parent);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +114,8 @@ public abstract class StatusDisplayItem{
|
||||
}
|
||||
if(addFooter){
|
||||
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
|
||||
if(status.hasGapAfter)
|
||||
items.add(new GapStatusDisplayItem(parentID, fragment));
|
||||
}
|
||||
int i=1;
|
||||
for(StatusDisplayItem item:items){
|
||||
@@ -142,7 +146,9 @@ public abstract class StatusDisplayItem{
|
||||
FOOTER,
|
||||
ACCOUNT_CARD,
|
||||
ACCOUNT,
|
||||
HASHTAG
|
||||
HASHTAG,
|
||||
GAP,
|
||||
EXTENDED_FOOTER
|
||||
}
|
||||
|
||||
public static abstract class Holder<T extends StatusDisplayItem> extends BindableViewHolder<T> implements UsableRecyclerView.DisableableClickable{
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.widget.TextView;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.fragments.BaseStatusListFragment;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
|
||||
import org.joinmastodon.android.ui.views.LinkedTextView;
|
||||
|
||||
@@ -20,7 +21,8 @@ import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||
|
||||
public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
private CharSequence text;
|
||||
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
|
||||
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper(), spoilerEmojiHelper;
|
||||
private CharSequence parsedSpoilerText;
|
||||
public boolean textSelectable;
|
||||
public final Status status;
|
||||
|
||||
@@ -29,6 +31,11 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
this.text=text;
|
||||
this.status=status;
|
||||
emojiHelper.setText(text);
|
||||
if(!TextUtils.isEmpty(status.spoilerText)){
|
||||
parsedSpoilerText=HtmlParser.parseCustomEmoji(status.spoilerText, status.emojis);
|
||||
spoilerEmojiHelper=new CustomEmojiHelper();
|
||||
spoilerEmojiHelper.setText(parsedSpoilerText);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -38,11 +45,15 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
|
||||
@Override
|
||||
public int getImageCount(){
|
||||
if(spoilerEmojiHelper!=null && !status.spoilerRevealed)
|
||||
return spoilerEmojiHelper.getImageCount();
|
||||
return emojiHelper.getImageCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImageLoaderRequest getImageRequest(int index){
|
||||
if(spoilerEmojiHelper!=null && !status.spoilerRevealed)
|
||||
return spoilerEmojiHelper.getImageRequest(index);
|
||||
return emojiHelper.getImageRequest(index);
|
||||
}
|
||||
|
||||
@@ -65,7 +76,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
text.setTextIsSelectable(item.textSelectable);
|
||||
text.setInvalidateOnEveryFrame(false);
|
||||
if(!TextUtils.isEmpty(item.status.spoilerText)){
|
||||
spoilerTitle.setText(item.status.spoilerText);
|
||||
spoilerTitle.setText(item.parsedSpoilerText);
|
||||
if(item.status.spoilerRevealed){
|
||||
spoilerOverlay.setVisibility(View.GONE);
|
||||
text.setVisibility(View.VISIBLE);
|
||||
@@ -84,8 +95,9 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
|
||||
@Override
|
||||
public void setImage(int index, Drawable image){
|
||||
item.emojiHelper.setImageDrawable(index, image);
|
||||
getEmojiHelper().setImageDrawable(index, image);
|
||||
text.invalidate();
|
||||
spoilerTitle.invalidate();
|
||||
if(image instanceof Animatable){
|
||||
((Animatable) image).start();
|
||||
if(image instanceof MovieDrawable)
|
||||
@@ -95,8 +107,12 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
|
||||
@Override
|
||||
public void clearImage(int index){
|
||||
item.emojiHelper.setImageDrawable(index, null);
|
||||
getEmojiHelper().setImageDrawable(index, null);
|
||||
text.invalidate();
|
||||
}
|
||||
|
||||
private CustomEmojiHelper getEmojiHelper(){
|
||||
return item.spoilerEmojiHelper!=null && !item.status.spoilerRevealed ? item.spoilerEmojiHelper : item.emojiHelper;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package org.joinmastodon.android.ui.drawables;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapShader;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.ColorFilter;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.Shader;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
public class SawtoothTearDrawable extends Drawable{
|
||||
private final Paint topPaint, bottomPaint;
|
||||
|
||||
private static final int TOP_SAWTOOTH_HEIGHT=5;
|
||||
private static final int BOTTOM_SAWTOOTH_HEIGHT=3;
|
||||
private static final int STROKE_WIDTH=2;
|
||||
private static final int SAWTOOTH_PERIOD=14;
|
||||
|
||||
public SawtoothTearDrawable(Context context){
|
||||
topPaint=makeShaderPaint(makeSawtoothTexture(context, TOP_SAWTOOTH_HEIGHT, SAWTOOTH_PERIOD, false, STROKE_WIDTH));
|
||||
bottomPaint=makeShaderPaint(makeSawtoothTexture(context, BOTTOM_SAWTOOTH_HEIGHT, SAWTOOTH_PERIOD, true, STROKE_WIDTH));
|
||||
Matrix matrix=new Matrix();
|
||||
//noinspection IntegerDivisionInFloatingPointContext
|
||||
matrix.setTranslate(V.dp(SAWTOOTH_PERIOD/2), 0);
|
||||
bottomPaint.getShader().setLocalMatrix(matrix);
|
||||
}
|
||||
|
||||
private Bitmap makeSawtoothTexture(Context context, int height, int period, boolean fillBottom, int strokeWidth){
|
||||
int actualStrokeWidth=V.dp(strokeWidth);
|
||||
int actualPeriod=V.dp(period);
|
||||
int actualHeight=V.dp(height);
|
||||
Bitmap bitmap=Bitmap.createBitmap(actualPeriod, actualHeight+actualStrokeWidth*2, Bitmap.Config.ARGB_8888);
|
||||
Canvas c=new Canvas(bitmap);
|
||||
Path path=new Path();
|
||||
//noinspection SuspiciousNameCombination
|
||||
path.moveTo(-actualPeriod/2f, actualStrokeWidth);
|
||||
path.lineTo(0, actualHeight+actualStrokeWidth);
|
||||
//noinspection SuspiciousNameCombination
|
||||
path.lineTo(actualPeriod/2f, actualStrokeWidth);
|
||||
path.lineTo(actualPeriod, actualHeight+actualStrokeWidth);
|
||||
//noinspection SuspiciousNameCombination
|
||||
path.lineTo(actualPeriod*1.5f, actualStrokeWidth);
|
||||
if(fillBottom){
|
||||
path.lineTo(actualPeriod*1.5f, actualHeight*20);
|
||||
path.lineTo(-actualPeriod/2f, actualHeight*20);
|
||||
}else{
|
||||
path.lineTo(actualPeriod*1.5f, -actualHeight);
|
||||
path.lineTo(-actualPeriod/2f, -actualHeight);
|
||||
}
|
||||
path.close();
|
||||
Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorWindowBackground));
|
||||
c.drawPath(path, paint);
|
||||
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorPollVoted));
|
||||
paint.setStrokeWidth(actualStrokeWidth);
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
c.drawPath(path, paint);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private Paint makeShaderPaint(Bitmap bitmap){
|
||||
BitmapShader shader=new BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP);
|
||||
Paint paint=new Paint();
|
||||
paint.setShader(shader);
|
||||
return paint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas){
|
||||
int strokeWidth=V.dp(STROKE_WIDTH);
|
||||
Rect bounds=getBounds();
|
||||
canvas.save();
|
||||
canvas.translate(bounds.left, bounds.top);
|
||||
canvas.drawRect(0, 0, bounds.width(), V.dp(TOP_SAWTOOTH_HEIGHT)+strokeWidth*2, topPaint);
|
||||
int bottomHeight=V.dp(BOTTOM_SAWTOOTH_HEIGHT)+strokeWidth*2;
|
||||
canvas.translate(0, bounds.height()-bottomHeight);
|
||||
canvas.drawRect(0, 0, bounds.width(), bottomHeight, bottomPaint);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAlpha(int alpha){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColorFilter(@Nullable ColorFilter colorFilter){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOpacity(){
|
||||
return PixelFormat.TRANSLUCENT;
|
||||
}
|
||||
}
|
||||
@@ -133,18 +133,18 @@ public class PhotoViewer implements ZoomPanView.Listener{
|
||||
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets){
|
||||
if(Build.VERSION.SDK_INT>=29){
|
||||
DisplayCutout cutout=insets.getDisplayCutout();
|
||||
Insets tappable=insets.getTappableElementInsets();
|
||||
if(cutout!=null){
|
||||
// Make controls extend beneath the cutout, and replace insets to avoid cutout insets being filled with "navigation bar color"
|
||||
Insets tappable=insets.getTappableElementInsets();
|
||||
int leftInset=Math.max(0, cutout.getSafeInsetLeft()-tappable.left);
|
||||
int rightInset=Math.max(0, cutout.getSafeInsetRight()-tappable.right);
|
||||
insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom);
|
||||
toolbarWrap.setPadding(leftInset, 0, rightInset, 0);
|
||||
videoControls.setPadding(leftInset, 0, rightInset, 0);
|
||||
}else{
|
||||
toolbarWrap.setPadding(0, 0, 0, 0);
|
||||
videoControls.setPadding(0, 0, 0, 0);
|
||||
}
|
||||
insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom);
|
||||
}
|
||||
uiOverlay.dispatchApplyWindowInsets(insets);
|
||||
int bottomInset=insets.getSystemWindowInsetBottom();
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.util.AttributeSet;
|
||||
@@ -54,6 +55,9 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
|
||||
private float lastFlingVelocityY;
|
||||
private float backgroundAlphaForTransition=1f;
|
||||
private boolean forceUpdateLayout;
|
||||
private int[] transitionCornerRadius;
|
||||
private Path transitionClipPath=new Path();
|
||||
private float[] tmpFloatArray=new float[8];
|
||||
|
||||
private static final String TAG="ZoomPanView";
|
||||
|
||||
@@ -148,10 +152,25 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
|
||||
child.getMatrix().mapRect(tmpRect2);
|
||||
tmpRect2.offset(child.getLeft(), child.getTop());
|
||||
canvas.save();
|
||||
canvas.clipRect(interpolate(tmpRect2.left, tmpRect.left, cropAnimationValue),
|
||||
interpolate(tmpRect2.top, tmpRect.top, cropAnimationValue),
|
||||
interpolate(tmpRect2.right, tmpRect.right, cropAnimationValue),
|
||||
interpolate(tmpRect2.bottom, tmpRect.bottom, cropAnimationValue));
|
||||
if(transitionCornerRadius!=null){
|
||||
float radiusScale=child.getScaleX();
|
||||
tmpFloatArray[0]=tmpFloatArray[1]=(float)transitionCornerRadius[0]*radiusScale*(1f-cropAnimationValue);
|
||||
tmpFloatArray[2]=tmpFloatArray[3]=(float)transitionCornerRadius[1]*radiusScale*(1f-cropAnimationValue);
|
||||
tmpFloatArray[4]=tmpFloatArray[5]=(float)transitionCornerRadius[2]*radiusScale*(1f-cropAnimationValue);
|
||||
tmpFloatArray[6]=tmpFloatArray[7]=(float)transitionCornerRadius[3]*radiusScale*(1f-cropAnimationValue);
|
||||
transitionClipPath.rewind();
|
||||
transitionClipPath.addRoundRect(interpolate(tmpRect2.left, tmpRect.left, cropAnimationValue),
|
||||
interpolate(tmpRect2.top, tmpRect.top, cropAnimationValue),
|
||||
interpolate(tmpRect2.right, tmpRect.right, cropAnimationValue),
|
||||
interpolate(tmpRect2.bottom, tmpRect.bottom, cropAnimationValue),
|
||||
tmpFloatArray, Path.Direction.CW);
|
||||
canvas.clipPath(transitionClipPath);
|
||||
}else{
|
||||
canvas.clipRect(interpolate(tmpRect2.left, tmpRect.left, cropAnimationValue),
|
||||
interpolate(tmpRect2.top, tmpRect.top, cropAnimationValue),
|
||||
interpolate(tmpRect2.right, tmpRect.right, cropAnimationValue),
|
||||
interpolate(tmpRect2.bottom, tmpRect.bottom, cropAnimationValue));
|
||||
}
|
||||
boolean res=super.drawChild(canvas, child, drawingTime);
|
||||
canvas.restore();
|
||||
return res;
|
||||
@@ -189,6 +208,18 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
|
||||
return initialScale;
|
||||
}
|
||||
|
||||
private void validateAndSetCornerRadius(int[] cornerRadius){
|
||||
transitionCornerRadius=null;
|
||||
if(cornerRadius!=null && cornerRadius.length==4){
|
||||
for(int corner:cornerRadius){
|
||||
if(corner>0){
|
||||
transitionCornerRadius=cornerRadius;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void animateIn(Rect rect, int[] cornerRadius){
|
||||
int[] loc={0, 0};
|
||||
getLocationOnScreen(loc);
|
||||
@@ -204,6 +235,7 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
|
||||
animatingTransition=true;
|
||||
|
||||
matrix.getValues(matrixValues);
|
||||
validateAndSetCornerRadius(cornerRadius);
|
||||
|
||||
child.setAlpha(0f);
|
||||
setupAndStartTransitionAnim(new SpringAnimation(this, CROP_AND_FADE, 1f).setMinimumVisibleChange(DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE));
|
||||
@@ -233,6 +265,7 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
|
||||
animatingTransition=true;
|
||||
dismissAfterTransition=true;
|
||||
rawCropAndFadeValue=1f;
|
||||
validateAndSetCornerRadius(cornerRadius);
|
||||
|
||||
setupAndStartTransitionAnim(new SpringAnimation(this, CROP_AND_FADE, 0f).setMinimumVisibleChange(DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE));
|
||||
setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.SCALE_X, initialScale));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.joinmastodon.android.ui.text;
|
||||
|
||||
/**
|
||||
* A span to mark character ranges that should be deleted when copied to the clipboard.
|
||||
* Works with {@link org.joinmastodon.android.ui.views.LinkedTextView}.
|
||||
*/
|
||||
public class DeleteWhenCopiedSpan{
|
||||
}
|
||||
@@ -67,10 +67,9 @@ public class HtmlParser{
|
||||
|
||||
@Override
|
||||
public void head(@NonNull Node node, int depth){
|
||||
if(node instanceof TextNode){
|
||||
ssb.append(((TextNode) node).text());
|
||||
}else if(node instanceof Element){
|
||||
Element el=(Element)node;
|
||||
if(node instanceof TextNode textNode){
|
||||
ssb.append(textNode.text());
|
||||
}else if(node instanceof Element el){
|
||||
switch(el.nodeName()){
|
||||
case "a" -> {
|
||||
String href=el.attr("href");
|
||||
@@ -108,10 +107,9 @@ public class HtmlParser{
|
||||
|
||||
@Override
|
||||
public void tail(@NonNull Node node, int depth){
|
||||
if(node instanceof Element){
|
||||
Element el=(Element)node;
|
||||
if(node instanceof Element el){
|
||||
if("span".equals(el.nodeName()) && el.hasClass("ellipsis")){
|
||||
ssb.append('…');
|
||||
ssb.append("…", new DeleteWhenCopiedSpan(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}else if("p".equals(el.nodeName())){
|
||||
if(node.nextSibling()!=null)
|
||||
ssb.append("\n\n");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user