mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
461 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fbb59ddac | ||
|
|
fe4a4b6fad | ||
|
|
8dd5705f02 | ||
|
|
6723225885 | ||
|
|
fb19454259 | ||
|
|
c6f63a13d3 | ||
|
|
77057fcac5 | ||
|
|
676d5e9b88 | ||
|
|
d6dda0053a | ||
|
|
d33e62367e | ||
|
|
47d7a53c1c | ||
|
|
6fcf472638 | ||
|
|
e2f5a1a0e4 | ||
|
|
8ea25e735d | ||
|
|
a9d05070ed | ||
|
|
d78ec6b02a | ||
|
|
8971b4e190 | ||
|
|
dea0adaea4 | ||
|
|
5de4fbcbe7 | ||
|
|
2330e086ea | ||
|
|
11d51b3854 | ||
|
|
008469adbc | ||
|
|
bbe26c7a30 | ||
|
|
c2b9bb413d | ||
|
|
5c1c4f8db2 | ||
|
|
b46bb7ecc5 | ||
|
|
2fed65db8d | ||
|
|
66cc4b5526 | ||
|
|
91488f0350 | ||
|
|
f2429e011e | ||
|
|
1374f36b1f | ||
|
|
d185e65b3f | ||
|
|
64bf17b1a0 | ||
|
|
93b50468fd | ||
|
|
65ab756c2b | ||
|
|
e298285145 | ||
|
|
d730eeb9a1 | ||
|
|
0d63441611 | ||
|
|
f0ecf382e0 | ||
|
|
d0dee84031 | ||
|
|
ad59b691fd | ||
|
|
a256e34878 | ||
|
|
6ad00c112a | ||
|
|
0010a19c24 | ||
|
|
d78c7a91cb | ||
|
|
4a2dced02f | ||
|
|
2d1a63be57 | ||
|
|
9e207d0f24 | ||
|
|
efad94ddfd | ||
|
|
d49fde755e | ||
|
|
0224af18c0 | ||
|
|
53a4dfb702 | ||
|
|
4e4e798133 | ||
|
|
e8bd4a12ac | ||
|
|
dffed6e78f | ||
|
|
46a4f360f7 | ||
|
|
64a5071345 | ||
|
|
1fba93b4f0 | ||
|
|
caa67b03a8 | ||
|
|
5f1b2f9eb8 | ||
|
|
2968cb12aa | ||
|
|
8261b38de9 | ||
|
|
2c4489a0d0 | ||
|
|
52dcb73fd7 | ||
|
|
91a2f8ecfa | ||
|
|
6905e1b47f | ||
|
|
640b0d3174 | ||
|
|
a86cd8772f | ||
|
|
83be195a43 | ||
|
|
14bf73e2f1 | ||
|
|
efb7b06e4e | ||
|
|
8b9f37d678 | ||
|
|
e83f16f5c5 | ||
|
|
e648970b17 | ||
|
|
44badf8c86 | ||
|
|
516ff17eed | ||
|
|
3b1533eb82 | ||
|
|
2e7a955ada | ||
|
|
4aa961437b | ||
|
|
0d9399a1a4 | ||
|
|
9b62cf563a | ||
|
|
7c525e2f2f | ||
|
|
63888d55fd | ||
|
|
7287ead851 | ||
|
|
6fc829f4f1 | ||
|
|
d12b0ce9be | ||
|
|
393714eb42 | ||
|
|
fe363b8a2d | ||
|
|
064ce7e6e6 | ||
|
|
af72846998 | ||
|
|
899b4f7c90 | ||
|
|
2e5b41b711 | ||
|
|
809f048b44 | ||
|
|
51c0c44a2e | ||
|
|
811d6d8c67 | ||
|
|
c1b9bf42ad | ||
|
|
cafe742cd8 | ||
|
|
ba50ec052b | ||
|
|
386f97abf8 | ||
|
|
7678b1b100 | ||
|
|
9df82a78e7 | ||
|
|
c87466ada4 | ||
|
|
135eb6763a | ||
|
|
24a0be9c3a | ||
|
|
f090bc82ae | ||
|
|
3254479ee5 | ||
|
|
6cc4266435 | ||
|
|
3e48b293b0 | ||
|
|
492b9494ec | ||
|
|
b31d2d49d6 | ||
|
|
245bd4a44d | ||
|
|
6d4a887d00 | ||
|
|
f95023f59d | ||
|
|
e69c1cf3a9 | ||
|
|
b5229f5fe1 | ||
|
|
e2ef7cfd70 | ||
|
|
6e89e69fb6 | ||
|
|
1e6ee67932 | ||
|
|
0fead83279 | ||
|
|
66952c9212 | ||
|
|
38bb9f457d | ||
|
|
3e615bb152 | ||
|
|
f83a19260f | ||
|
|
69c3c41196 | ||
|
|
6c1a4f3971 | ||
|
|
035ba899ee | ||
|
|
33cc261b62 | ||
|
|
fee6f19d5e | ||
|
|
68e8a13f73 | ||
|
|
a639c3f36d | ||
|
|
a5f10fef60 | ||
|
|
d334c04c47 | ||
|
|
7c3d9aad1d | ||
|
|
ceb81aa2bf | ||
|
|
3ee743ce1a | ||
|
|
d1ffea5432 | ||
|
|
628e73ef69 | ||
|
|
764928a492 | ||
|
|
88638b0cae | ||
|
|
51384e654b | ||
|
|
aecfaa5fd8 | ||
|
|
fc5089f7f1 | ||
|
|
25520e7d2a | ||
|
|
7ea5be0152 | ||
|
|
9a7e678bbe | ||
|
|
1f0ca70c47 | ||
|
|
93705e5baf | ||
|
|
72e8fa3679 | ||
|
|
c08ce91197 | ||
|
|
b520a0faed | ||
|
|
80837abab5 | ||
|
|
5c78135f1a | ||
|
|
386658ed37 | ||
|
|
ab8b0beb02 | ||
|
|
bc89062681 | ||
|
|
14685be92d | ||
|
|
6d565a2960 | ||
|
|
3fc247d85c | ||
|
|
cfbc4536c6 | ||
|
|
dcf825c43e | ||
|
|
8ae05d9964 | ||
|
|
f11faa87b1 | ||
|
|
bfbaabd7e8 | ||
|
|
d35d6a44fe | ||
|
|
9c7265565e | ||
|
|
293b023add | ||
|
|
bee5c1bfa6 | ||
|
|
c93a6dffc4 | ||
|
|
60dadc0088 | ||
|
|
a26c7e3d97 | ||
|
|
9e2b2cedd6 | ||
|
|
636d59659d | ||
|
|
63e8391893 | ||
|
|
2f354e4b81 | ||
|
|
b3a369d91b | ||
|
|
4406532994 | ||
|
|
8fd5176e22 | ||
|
|
e3396065c6 | ||
|
|
84600c016e | ||
|
|
64d96ab1d7 | ||
|
|
6d6720a511 | ||
|
|
17232a1909 | ||
|
|
eb02049507 | ||
|
|
ed7ad2f51b | ||
|
|
3e0202e6cb | ||
|
|
d59a68ec2e | ||
|
|
f249884907 | ||
|
|
c823d1689f | ||
|
|
b10f268e69 | ||
|
|
eff21c4f8f | ||
|
|
5537208abb | ||
|
|
53a5d3e88e | ||
|
|
20f840fd99 | ||
|
|
dc940c0608 | ||
|
|
f5c77d41f1 | ||
|
|
014c613577 | ||
|
|
d369c2c658 | ||
|
|
536868d3e5 | ||
|
|
5706738898 | ||
|
|
2947c2c7d0 | ||
|
|
18ac527629 | ||
|
|
6d80d14421 | ||
|
|
e60d8c5c20 | ||
|
|
51b4cf444a | ||
|
|
f78ec5cc90 | ||
|
|
fd6a63a76c | ||
|
|
e0f186bb94 | ||
|
|
9fadab5bb9 | ||
|
|
ea768fb560 | ||
|
|
71cb18f360 | ||
|
|
3a1694ac9a | ||
|
|
91c47b6e87 | ||
|
|
fe4e03df39 | ||
|
|
d110b9fbbf | ||
|
|
dc8c177f20 | ||
|
|
1beacd240e | ||
|
|
083394b928 | ||
|
|
6de8d9d7e0 | ||
|
|
fb4457e767 | ||
|
|
d4c1907349 | ||
|
|
c92926f3ab | ||
|
|
8f4f8f7953 | ||
|
|
5a3ea1c258 | ||
|
|
a567ec70ad | ||
|
|
e402081777 | ||
|
|
ed9c27341f | ||
|
|
56d9c9a50f | ||
|
|
d2a22f4ca0 | ||
|
|
09582fd0b1 | ||
|
|
1d5dfbfd29 | ||
|
|
044d47adad | ||
|
|
cd9465c0e7 | ||
|
|
d179c93c0b | ||
|
|
3c84eae647 | ||
|
|
53a146d0e5 | ||
|
|
57db7f0272 | ||
|
|
b820b6564f | ||
|
|
3908c42c7f | ||
|
|
1793fe40bf | ||
|
|
0857695332 | ||
|
|
a7b3bac8f8 | ||
|
|
e6f122d8b7 | ||
|
|
b2cdc2ddef | ||
|
|
94057059bb | ||
|
|
927ef17896 | ||
|
|
33f915879b | ||
|
|
98bca48359 | ||
|
|
171f763908 | ||
|
|
605d83e530 | ||
|
|
5f710b7304 | ||
|
|
1c6593fb17 | ||
|
|
33fe34d3b3 | ||
|
|
e257fec92c | ||
|
|
6a70a6c72f | ||
|
|
270d6b411a | ||
|
|
803b1e861e | ||
|
|
7300dc3d78 | ||
|
|
405d64402b | ||
|
|
fe1bff1074 | ||
|
|
2952ca1978 | ||
|
|
32dcd7747b | ||
|
|
eca5a1de15 | ||
|
|
886442ac52 | ||
|
|
8521611008 | ||
|
|
df647e4047 | ||
|
|
af26026d25 | ||
|
|
43be55f3d6 | ||
|
|
9b3cb92cb3 | ||
|
|
f2b0ce5fd0 | ||
|
|
787ca98daf | ||
|
|
71517dc454 | ||
|
|
949301d58f | ||
|
|
cf8ac4faae | ||
|
|
ad3aa124fc | ||
|
|
2326aa9f7c | ||
|
|
acfd687d74 | ||
|
|
9b9183395d | ||
|
|
45b4902b2b | ||
|
|
6056d60d32 | ||
|
|
61af248c09 | ||
|
|
5a580db1b8 | ||
|
|
a72ae49e93 | ||
|
|
40a01219cc | ||
|
|
c652390a9c | ||
|
|
0916a97526 | ||
|
|
d76ef0a1ca | ||
|
|
3a70ce1587 | ||
|
|
9f96e84378 | ||
|
|
985771beba | ||
|
|
3967633f77 | ||
|
|
ee5699c2de | ||
|
|
b8f4d9f0bb | ||
|
|
8b1de47517 | ||
|
|
c6ff514ba2 | ||
|
|
50f2d18b61 | ||
|
|
fa7ccdfc20 | ||
|
|
fe02519737 | ||
|
|
f2e9fbebea | ||
|
|
52af109b4e | ||
|
|
833e6bc3e1 | ||
|
|
9bed6fdb9a | ||
|
|
a4a867ebcd | ||
|
|
4da2f18957 | ||
|
|
4141a52d51 | ||
|
|
4dbc71d577 | ||
|
|
3b5d792562 | ||
|
|
dd7f3225c6 | ||
|
|
3477e7b8ba | ||
|
|
a3782bb459 | ||
|
|
d4003eee04 | ||
|
|
c9bbb66284 | ||
|
|
1498fa9868 | ||
|
|
26b493aa1d | ||
|
|
f9d652f849 | ||
|
|
30b0ee444f | ||
|
|
bf8341ef58 | ||
|
|
f9f1b0d98e | ||
|
|
743a4c9d87 | ||
|
|
275baa7497 | ||
|
|
ac1fb879c3 | ||
|
|
270ad56450 | ||
|
|
d155241efc | ||
|
|
d61927cbee | ||
|
|
996b68f9c6 | ||
|
|
f6913663d1 | ||
|
|
58600e13a8 | ||
|
|
54e15ad3cd | ||
|
|
777c652bf5 | ||
|
|
7fb1473e21 | ||
|
|
95db1ec84f | ||
|
|
7438973a20 | ||
|
|
fe31855860 | ||
|
|
d7bce4414d | ||
|
|
b4a81c137c | ||
|
|
a6390f58d9 | ||
|
|
ca3f4c3ec6 | ||
|
|
53bfd40a31 | ||
|
|
798be25c43 | ||
|
|
ebe1bcedd0 | ||
|
|
4731575b87 | ||
|
|
383e761372 | ||
|
|
54a95e6a4e | ||
|
|
3105ade755 | ||
|
|
f651034303 | ||
|
|
7554dc010c | ||
|
|
70a962062b | ||
|
|
5cc7f3bf2b | ||
|
|
b040e0ca13 | ||
|
|
775094356c | ||
|
|
0771b179b5 | ||
|
|
8af307e8bb | ||
|
|
e6ec3f1ce4 | ||
|
|
b4395e7585 | ||
|
|
f8acb26763 | ||
|
|
aa931a4cc5 | ||
|
|
e1cf188fbe | ||
|
|
9e57a706ae | ||
|
|
8c73e981b5 | ||
|
|
87f5007fd3 | ||
|
|
5c42bb0282 | ||
|
|
415252b770 | ||
|
|
4f43d7cde5 | ||
|
|
c819a23f83 | ||
|
|
4a00a9a80d | ||
|
|
1f5f6b251c | ||
|
|
c925b82e86 | ||
|
|
96a5d1a658 | ||
|
|
36154422f1 | ||
|
|
5b2a878f5d | ||
|
|
976d96ef35 | ||
|
|
94b31fe5f9 | ||
|
|
cd2a59ae43 | ||
|
|
e8f5ef3aa3 | ||
|
|
887fc33073 | ||
|
|
c24ba18207 | ||
|
|
5137009f5f | ||
|
|
ba6bd6a2d2 | ||
|
|
bfc16d0878 | ||
|
|
94c41e0c6d | ||
|
|
33454cc2f8 | ||
|
|
d969a483a9 | ||
|
|
a51104fd82 | ||
|
|
a536388123 | ||
|
|
c7cb4b7015 | ||
|
|
fbb1b59e34 | ||
|
|
7d7f047f55 | ||
|
|
e9e0b0f57f | ||
|
|
baf0372537 | ||
|
|
f62716a226 | ||
|
|
9b1c47f4a5 | ||
|
|
a4dd3aa488 | ||
|
|
9dbe7379cd | ||
|
|
ad353d2d1a | ||
|
|
32b2b1ace8 | ||
|
|
13710dde06 | ||
|
|
915ddd0bad | ||
|
|
52ef356c06 | ||
|
|
69481a046b | ||
|
|
157802142c | ||
|
|
54462f8d26 | ||
|
|
fcf612d22a | ||
|
|
e8eea4be25 | ||
|
|
cce9d6cf68 | ||
|
|
143f58fd68 | ||
|
|
6fe66986be | ||
|
|
68e8abecb2 | ||
|
|
bb698a351e | ||
|
|
4506ff9c93 | ||
|
|
f88a4d56c7 | ||
|
|
fbe0f584af | ||
|
|
e28f26d471 | ||
|
|
80a9a2a712 | ||
|
|
2a47cd4fd1 | ||
|
|
ce207aed39 | ||
|
|
244e3f3b29 | ||
|
|
6a44d283f9 | ||
|
|
6addce3d20 | ||
|
|
8aae101ef6 | ||
|
|
d1bbdef5c4 | ||
|
|
b78967f8be | ||
|
|
01a2f746d0 | ||
|
|
d063c2990c | ||
|
|
e31ee031cb | ||
|
|
8fc489cc59 | ||
|
|
ffb2a19963 | ||
|
|
b271c1775c | ||
|
|
f937e95b35 | ||
|
|
1613e79860 | ||
|
|
dd40b4f205 | ||
|
|
2ad9401ee6 | ||
|
|
69ade2f6e6 | ||
|
|
4006e62a82 | ||
|
|
ec88a8b157 | ||
|
|
128211ecd2 | ||
|
|
8f53537ebc | ||
|
|
58c93e0cce | ||
|
|
c35b5e3703 | ||
|
|
be8848fb1b | ||
|
|
a3efd15b95 | ||
|
|
a1930c232b | ||
|
|
58a4fb18ae | ||
|
|
be4870da1a | ||
|
|
279894f9d7 | ||
|
|
ffa03e1d0d | ||
|
|
719f5c2ac6 | ||
|
|
baa3a21dd2 | ||
|
|
8cb84c2395 | ||
|
|
ee52e725d9 | ||
|
|
f3e0c31087 | ||
|
|
92ccdfc55f | ||
|
|
0528e59572 | ||
|
|
0ab3219115 | ||
|
|
724cfddb69 | ||
|
|
7fcc28a320 | ||
|
|
94051f178b | ||
|
|
8e4f339aa1 | ||
|
|
425aa69691 | ||
|
|
1c65334b91 | ||
|
|
726d04f0f4 | ||
|
|
9d1ab5a9b8 | ||
|
|
09bad79870 |
32
README.md
32
README.md
@@ -4,6 +4,11 @@
|
||||
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
|
||||
[<img src="./docs/design/obtainium.png"
|
||||
alt="Get it on Obtaininum"
|
||||
height="80">](https://github.com/ImranR98/Obtainium)
|
||||
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
|
||||
height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
|
||||
@@ -11,15 +16,15 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
alt="Get it on Google Play"
|
||||
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
- [x] Reactions (likes NIP-25, boost, reply)
|
||||
- [x] Replies and mentions (NIP-10)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [x] Reposts (NIP-18)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] View Threads
|
||||
- [x] Private Messages (NIP-04)
|
||||
@@ -36,7 +41,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
- [x] Nostr Address (NIP-05)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
@@ -56,19 +61,28 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Zap Forwarding (NIP-57)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
|
||||
- [x] Audio Tracks (zapstr.live) (Kind:31337)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [x] Generic Reposts (kind:16)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Relay Pages (NIP-11)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Moderated Communities (NIP-172)
|
||||
- [x] Emoji Packs (Kind:30030)
|
||||
- [x] Personal Emoji Lists (Kind:10030)
|
||||
- [x] Classifieds (Kind:30403)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Relay Pages (NIP-11)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Workspaces
|
||||
- [ ] Expiration Support (NIP-40)
|
||||
@@ -184,6 +198,12 @@ Information that you share is publicly visible to anyone reading from relays tha
|
||||
## Information Permanence ##
|
||||
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
|
||||
|
||||
# Screenshots
|
||||
|
||||
| Home Feed | Messages | Live Streams | Notifications |
|
||||
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
|
||||
|  |  |  |  |
|
||||
|
||||
# Contributing
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
|
||||
|
||||
BIN
amethyst.png
BIN
amethyst.png
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 16 KiB |
@@ -13,14 +13,14 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 213
|
||||
versionName "0.60.0"
|
||||
versionCode 259
|
||||
versionName "0.70.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW" )
|
||||
resConfigs("ar", "cs", "de", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -110,13 +110,13 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
|
||||
implementation 'net.engawapg.lib:zoomable:1.4.2'
|
||||
implementation "net.engawapg.lib:zoomable:1.4.3"
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
@@ -134,15 +134,14 @@ dependencies {
|
||||
|
||||
// link preview
|
||||
implementation 'org.jsoup:jsoup:1.16.1'
|
||||
//implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
|
||||
implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
|
||||
// important for proxy / tor
|
||||
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
|
||||
implementation "androidx.media3:media3-datasource-okhttp:${exoplayer_version}"
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
@@ -170,9 +169,15 @@ dependencies {
|
||||
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
|
||||
|
||||
// Markdown (With fix for full-image bleeds)
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:main-SNAPSHOT')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:main-SNAPSHOT')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:main-SNAPSHOT')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
|
||||
implementation 'androidx.media3:media3-ui:1.1.0'
|
||||
implementation 'androidx.media3:media3-session:1.1.0'
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation 'com.google.mlkit:language-id:17.0.4'
|
||||
@@ -181,7 +186,7 @@ dependencies {
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.1.0')
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.1.1')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
// Charts
|
||||
@@ -194,12 +199,12 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
|
||||
|
||||
// Video compression lib
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.2.3'
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
|
||||
// Image compression lib
|
||||
implementation 'id.zelory:compressor:3.0.1'
|
||||
|
||||
// Automatic memory leak detection
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.5'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,17 @@ import org.junit.runner.RunWith
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TranslationsTest {
|
||||
|
||||
fun translatePT(text: String, translateTo: String): String? {
|
||||
fun translateTo(text: String, translateTo: String): String? {
|
||||
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
|
||||
return Tasks.await(task).result
|
||||
}
|
||||
|
||||
fun assertTranslate(expected: String, input: String, translateTo: String) {
|
||||
assertEquals(null, expected, translatePT(input, translateTo))
|
||||
assertEquals(null, expected, translateTo(input, translateTo))
|
||||
}
|
||||
|
||||
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
|
||||
val translated = translatePT(input, translateTo)!!
|
||||
val translated = translateTo(input, translateTo)!!
|
||||
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
|
||||
}
|
||||
|
||||
@@ -86,4 +86,17 @@ class TranslationsTest {
|
||||
"en"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testJapaneseTranslationsOfUrl() {
|
||||
assertTranslateContains(
|
||||
"https://youtu.be/wMYFmCDy_Eg",
|
||||
"うちの会社の小さい先輩の話 第1話「うちの会社の先輩は小さくて可愛い」\n" +
|
||||
"\n" +
|
||||
"https://youtu.be/wMYFmCDy_Eg\n" +
|
||||
"\n" +
|
||||
"先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど",
|
||||
"en"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
<!-- To know receive notifications when the app connects/disconnects from the web -->
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<!-- Audio/Video Playback -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<!-- Keeps screen on while playing videos -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Old permission to access media -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -51,6 +58,7 @@
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
@@ -91,6 +99,17 @@
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:stopWithTask="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 22 KiB |
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
@@ -43,6 +44,7 @@ private object PrefKeys {
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val FOLLOWING_COMMUNITIES = "following_communities"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
@@ -55,6 +57,7 @@ private object PrefKeys {
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
@@ -65,6 +68,11 @@ private object PrefKeys {
|
||||
const val WARN_ABOUT_REPORTS = "warn_about_reports"
|
||||
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
|
||||
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
|
||||
const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images"
|
||||
const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback"
|
||||
const val THEME = "theme"
|
||||
const val PREFERRED_LANGUAGE = "preferred_Language"
|
||||
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
@@ -204,6 +212,7 @@ object LocalPreferences {
|
||||
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
|
||||
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
|
||||
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
|
||||
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
|
||||
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||
@@ -216,6 +225,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
|
||||
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
@@ -232,6 +242,47 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
|
||||
}
|
||||
}.apply()
|
||||
|
||||
val globalPrefs = encryptedPreferences()
|
||||
globalPrefs.edit().apply {
|
||||
if (account.settings.automaticallyShowImages == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages!!)
|
||||
}
|
||||
|
||||
if (account.settings.automaticallyStartPlayback == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_START_PLAYBACK)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback!!)
|
||||
}
|
||||
if (account.settings.automaticallyShowUrlPreview == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview!!)
|
||||
}
|
||||
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun updateTheme(theme: Int) {
|
||||
encryptedPreferences().edit().apply {
|
||||
putInt(PrefKeys.THEME, theme)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun getTheme(): Int {
|
||||
encryptedPreferences().apply {
|
||||
return getInt(PrefKeys.THEME, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPreferredLanguage(): String {
|
||||
var language = ""
|
||||
encryptedPreferences().apply {
|
||||
language = getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
|
||||
}
|
||||
return language
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
@@ -245,6 +296,7 @@ object LocalPreferences {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
val localRelays = gson.fromJson(
|
||||
getString(PrefKeys.RELAYS, "[]"),
|
||||
@@ -256,6 +308,7 @@ object LocalPreferences {
|
||||
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
@@ -334,9 +387,32 @@ object LocalPreferences {
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val settings = Settings()
|
||||
encryptedPreferences().apply {
|
||||
settings.automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
|
||||
getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
settings.automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
settings.automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
|
||||
getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
|
||||
}
|
||||
|
||||
val a = Account(
|
||||
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels = followingChannels,
|
||||
followingCommunities = followingCommunities,
|
||||
hiddenUsers = hiddenUsers,
|
||||
localRelays = localRelays,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
@@ -349,6 +425,7 @@ object LocalPreferences {
|
||||
defaultHomeFollowList = defaultHomeFollowList,
|
||||
defaultStoriesFollowList = defaultStoriesFollowList,
|
||||
defaultNotificationFollowList = defaultNotificationFollowList,
|
||||
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
@@ -358,7 +435,8 @@ object LocalPreferences {
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
settings = settings
|
||||
)
|
||||
|
||||
return a
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.LruCache
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.STATE_IDLE
|
||||
import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
class MultiPlayerPlaybackManager(
|
||||
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
|
||||
private val cachedPositions: VideoViewedPositionCache
|
||||
) {
|
||||
// protects from LruCache killing playing sessions
|
||||
private val playingMap = mutableMapOf<String, MediaSession>()
|
||||
|
||||
private val cache =
|
||||
object : LruCache<String, MediaSession>(10) { // up to 10 videos in the screen at the same time
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: String?,
|
||||
oldValue: MediaSession?,
|
||||
newValue: MediaSession?
|
||||
) {
|
||||
super.entryRemoved(evicted, key, oldValue, newValue)
|
||||
|
||||
if (!playingMap.contains(key)) {
|
||||
oldValue?.let {
|
||||
it.player.release()
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCallbackIntent(callbackUri: String, applicationContext: Context): PendingIntent {
|
||||
return PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun getMediaSession(id: String, uri: String, callbackUri: String?, context: Context, applicationContext: Context): MediaSession {
|
||||
val existingSession = playingMap.get(id) ?: cache.get(id)
|
||||
if (existingSession != null) return existingSession
|
||||
|
||||
val player = ExoPlayer.Builder(context).run {
|
||||
dataSourceFactory?.let { setMediaSourceFactory(it) }
|
||||
build()
|
||||
}
|
||||
|
||||
player.apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
volume = 0f
|
||||
setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
}
|
||||
|
||||
val mediaSession = MediaSession.Builder(context, player).run {
|
||||
callbackUri?.let {
|
||||
setSessionActivity(getCallbackIntent(it, applicationContext))
|
||||
}
|
||||
setId(id)
|
||||
build()
|
||||
}
|
||||
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) {
|
||||
playingMap.put(id, mediaSession)
|
||||
} else {
|
||||
cache.put(id, mediaSession)
|
||||
playingMap.remove(id, mediaSession)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
STATE_IDLE -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
STATE_READY -> {
|
||||
cachedPositions.get(uri)?.let { lastPosition ->
|
||||
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
|
||||
player.seekTo(lastPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cache.put(id, mediaSession)
|
||||
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
fun releaseAppPlayers() {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
cache.evictAll()
|
||||
playingMap.forEach {
|
||||
it.value.player.release()
|
||||
it.value.release()
|
||||
}
|
||||
playingMap.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun playingContent(): Collection<MediaSession> {
|
||||
return playingMap.values
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
|
||||
object PlaybackClientController {
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun prepareController(
|
||||
controllerID: String,
|
||||
videoUri: String,
|
||||
callbackUri: String?,
|
||||
context: Context,
|
||||
onReady: (MediaController) -> Unit
|
||||
) {
|
||||
// creating a bundle object
|
||||
// creating a bundle object
|
||||
val bundle = Bundle()
|
||||
bundle.putString("id", controllerID)
|
||||
bundle.putString("uri", videoUri)
|
||||
bundle.putString("callbackUri", callbackUri)
|
||||
|
||||
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
val controllerFuture = MediaController
|
||||
.Builder(context, sessionTokenLocal)
|
||||
.setConnectionHints(bundle)
|
||||
.buildAsync()
|
||||
|
||||
controllerFuture.addListener(
|
||||
{
|
||||
val controller = controllerFuture.get()
|
||||
onReady(controller)
|
||||
},
|
||||
MoreExecutors.directExecutor()
|
||||
)
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt
Normal file
124
app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
@UnstableApi // Extend MediaSessionService
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var videoViewedPositionCache = VideoViewedPositionCache()
|
||||
|
||||
private var managerHls: MultiPlayerPlaybackManager? = null
|
||||
private var managerProgressive: MultiPlayerPlaybackManager? = null
|
||||
private var managerLocal: MultiPlayerPlaybackManager? = null
|
||||
|
||||
fun newHslDataSource(): MediaSource.Factory {
|
||||
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
|
||||
}
|
||||
|
||||
fun newProgressiveDataSource(): MediaSource.Factory {
|
||||
return ProgressiveMediaSource.Factory(VideoCache.get(applicationContext, HttpClient.getHttpClient()))
|
||||
}
|
||||
|
||||
// Create your Player and MediaSession in the onCreate lifecycle event
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClient.proxyChangeListeners.add {
|
||||
val toDestroyHls = managerHls
|
||||
val toDestroyProgressive = managerProgressive
|
||||
|
||||
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
|
||||
toDestroyHls?.releaseAppPlayers()
|
||||
toDestroyProgressive?.releaseAppPlayers()
|
||||
}
|
||||
|
||||
setMediaNotificationProvider(
|
||||
DefaultMediaNotificationProvider.Builder(applicationContext).build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
managerHls?.releaseAppPlayers()
|
||||
managerLocal?.releaseAppPlayers()
|
||||
managerProgressive?.releaseAppPlayers()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
|
||||
return if (fileName.startsWith("file")) {
|
||||
managerLocal
|
||||
} else if (fileName.endsWith("m3u8")) {
|
||||
managerHls
|
||||
} else {
|
||||
managerProgressive
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
// Updates any new player ready
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
|
||||
// Overrides the notification with any player actually playing
|
||||
managerHls?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides again with playing with audio
|
||||
managerHls?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a MediaSession to link with the MediaController that is making
|
||||
// this request.
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
val id = controllerInfo.connectionHints.getString("id") ?: return null
|
||||
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
|
||||
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
|
||||
|
||||
val manager = getAppropriateMediaSessionManager(uri)
|
||||
|
||||
return manager?.getMediaSession(id, uri, callbackUri, context = this, applicationContext = applicationContext)
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,13 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
@@ -23,6 +27,7 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
|
||||
object ServiceManager {
|
||||
private var account: Account? = null
|
||||
@@ -55,9 +60,6 @@ object ServiceManager {
|
||||
.build()
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(context.applicationContext)
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
@@ -66,11 +68,15 @@ object ServiceManager {
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
NostrDiscoveryDataSource.account = myAccount
|
||||
ImageUploader.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
NostrDiscoveryDataSource.start()
|
||||
NostrVideoDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
@@ -85,9 +91,13 @@ object ServiceManager {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrDiscoveryDataSource.stop()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrCommunityDataSource.stop()
|
||||
NostrHashtagDataSource.stop()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
object VideoCache {
|
||||
@UnstableApi object VideoCache {
|
||||
|
||||
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
|
||||
|
||||
@@ -20,33 +21,34 @@ object VideoCache {
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
@Synchronized
|
||||
fun init(context: Context) {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
private fun init(context: Context, client: OkHttpClient) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
} else {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
renewCacheFactory(client)
|
||||
}
|
||||
|
||||
fun get(): CacheDataSource.Factory {
|
||||
// This method should be called when proxy setting changes.
|
||||
fun renewCacheFactory(client: OkHttpClient) {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(client)
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
fun get(context: Context, client: OkHttpClient): CacheDataSource.Factory {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
init(context, client)
|
||||
}
|
||||
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.util.LruCache
|
||||
|
||||
class VideoViewedPositionCache {
|
||||
val cachedPosition = LruCache<String, Long>(100)
|
||||
|
||||
fun add(uri: String, position: Long) {
|
||||
cachedPosition.put(uri, position)
|
||||
}
|
||||
|
||||
fun get(uri: String): Long? {
|
||||
return cachedPosition.get(uri)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
||||
@@ -18,6 +20,10 @@ import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47URI
|
||||
import com.vitorpamplona.amethyst.ui.note.combineWith
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -49,8 +55,11 @@ val KIND3_FOLLOWS = " All Follows "
|
||||
@Stable
|
||||
class Account(
|
||||
val loggedIn: Persona,
|
||||
var followingChannels: Set<String> = DefaultChannels,
|
||||
var hiddenUsers: Set<String> = setOf(),
|
||||
|
||||
var followingChannels: Set<String> = DefaultChannels, // deprecated
|
||||
var followingCommunities: Set<String> = setOf(), // deprecated
|
||||
var hiddenUsers: Set<String> = setOf(), // deprecated
|
||||
|
||||
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
|
||||
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
@@ -62,6 +71,7 @@ class Account(
|
||||
var defaultHomeFollowList: String = KIND3_FOLLOWS,
|
||||
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
|
||||
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
|
||||
var defaultDiscoveryFollowList: String = GLOBAL_FOLLOWS,
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
@@ -71,9 +81,10 @@ class Account(
|
||||
var showSensitiveContent: Boolean? = null,
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>()
|
||||
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
|
||||
var settings: Settings = Settings()
|
||||
) {
|
||||
var transientHiddenUsers: Set<String> = setOf()
|
||||
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
|
||||
|
||||
// Observers line up here.
|
||||
val live: AccountLiveData = AccountLiveData(this)
|
||||
@@ -81,14 +92,54 @@ class Account(
|
||||
val liveLastRead: AccountLiveData = AccountLiveData(this)
|
||||
val saveable: AccountLiveData = AccountLiveData(this)
|
||||
|
||||
@Immutable
|
||||
data class LiveHiddenUsers(
|
||||
val hiddenUsers: ImmutableSet<String>,
|
||||
val spammers: ImmutableSet<String>,
|
||||
val showSensitiveContent: Boolean?
|
||||
)
|
||||
|
||||
val liveHiddenUsers: LiveData<LiveHiddenUsers> = live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent ->
|
||||
val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(loggedIn.privKey)
|
||||
LiveHiddenUsers(
|
||||
hiddenUsers = liveBlockedUsers ?: persistentSetOf(),
|
||||
spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(),
|
||||
showSensitiveContent = showSensitiveContent
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
var userProfileCache: User? = null
|
||||
|
||||
fun updateAutomaticallyStartPlayback(
|
||||
automaticallyStartPlayback: Boolean?
|
||||
) {
|
||||
settings.automaticallyStartPlayback = automaticallyStartPlayback
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateAutomaticallyShowUrlPreview(
|
||||
automaticallyShowUrlPreview: Boolean?
|
||||
) {
|
||||
settings.automaticallyShowUrlPreview = automaticallyShowUrlPreview
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateAutomaticallyShowImages(
|
||||
automaticallyShowImages: Boolean?
|
||||
) {
|
||||
settings.automaticallyShowImages = automaticallyShowImages
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
OptOutFromFilters.start(warnAboutPostsWithReports, filterSpamFromStrangers)
|
||||
if (!filterSpamFromStrangers) {
|
||||
transientHiddenUsers = setOf()
|
||||
transientHiddenUsers = persistentSetOf()
|
||||
}
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
@@ -102,10 +153,6 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun followingChannels(): List<Channel> {
|
||||
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean {
|
||||
return loggedIn.privKey != null
|
||||
}
|
||||
@@ -114,21 +161,25 @@ class Account(
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val follows = contactList?.follows() ?: emptyList()
|
||||
val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
|
||||
if (contactList != null && follows.isNotEmpty()) {
|
||||
val event = ContactListEvent.create(
|
||||
follows,
|
||||
followsTags,
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
val event = ContactListEvent.updateRelayList(
|
||||
earlierVersion = contactList,
|
||||
relayUse = relays,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
} else {
|
||||
val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
|
||||
val event = ContactListEvent.createFromScratch(
|
||||
followUsers = listOf(),
|
||||
followTags = listOf(),
|
||||
followCommunities = listOf(),
|
||||
followEvents = DefaultChannels.toList(),
|
||||
relayUse = relays,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
// Keep this local to avoid erasing a good contact list.
|
||||
// Client.send(event)
|
||||
@@ -170,6 +221,19 @@ class Account(
|
||||
return
|
||||
}
|
||||
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrl.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.create(emojiUrl, it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.create(reaction, it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
@@ -322,6 +386,12 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createHTTPAuthorization(url: String, method: String, body: String? = null): HTTPAuthorizationEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
return HTTPAuthorizationEvent.create(url, method, body, loggedIn.privKey!!)
|
||||
}
|
||||
|
||||
fun boost(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -349,27 +419,90 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateCommunitiesAndChannelsIfNeeded(latestContactList: ContactListEvent?): ContactListEvent? {
|
||||
if (latestContactList == null) return latestContactList
|
||||
|
||||
var returningContactList: ContactListEvent = latestContactList
|
||||
|
||||
if (followingCommunities.isNotEmpty()) {
|
||||
followingCommunities.forEach {
|
||||
ATag.parse(it, null)?.let {
|
||||
returningContactList = ContactListEvent.followAddressableEvent(returningContactList, it, loggedIn.privKey!!)
|
||||
}
|
||||
}
|
||||
followingCommunities = emptySet()
|
||||
}
|
||||
|
||||
if (followingChannels.isNotEmpty()) {
|
||||
followingChannels.forEach {
|
||||
returningContactList = ContactListEvent.followEvent(returningContactList, it, loggedIn.privKey!!)
|
||||
}
|
||||
followingChannels = emptySet()
|
||||
}
|
||||
|
||||
return returningContactList
|
||||
}
|
||||
|
||||
fun follow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val followingUsers = contactList?.follows() ?: emptyList()
|
||||
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
|
||||
|
||||
val event = if (contactList != null) {
|
||||
ContactListEvent.create(
|
||||
followingUsers.plus(Contact(user.pubkeyHex, null)),
|
||||
followingTags,
|
||||
contactList.relays(),
|
||||
loggedIn.privKey!!
|
||||
ContactListEvent.followUser(contactList, user.pubkeyHex, loggedIn.privKey!!)
|
||||
} else {
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = listOf(Contact(user.pubkeyHex, null)),
|
||||
followTags = emptyList(),
|
||||
followCommunities = emptyList(),
|
||||
followEvents = DefaultChannels.toList(),
|
||||
relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) },
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun follow(channel: Channel) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
|
||||
|
||||
val event = if (contactList != null) {
|
||||
ContactListEvent.followEvent(contactList, channel.idHex, loggedIn.privKey!!)
|
||||
} else {
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = emptyList(),
|
||||
followTags = emptyList(),
|
||||
followCommunities = emptyList(),
|
||||
followEvents = DefaultChannels.toList().plus(channel.idHex),
|
||||
relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) },
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun follow(community: AddressableNote) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
|
||||
|
||||
val event = if (contactList != null) {
|
||||
ContactListEvent.followAddressableEvent(contactList, community.address, loggedIn.privKey!!)
|
||||
} else {
|
||||
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
|
||||
ContactListEvent.create(
|
||||
listOf(Contact(user.pubkeyHex, null)),
|
||||
followingTags,
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = emptyList(),
|
||||
followTags = emptyList(),
|
||||
followCommunities = listOf(community.address),
|
||||
followEvents = DefaultChannels.toList(),
|
||||
relayUse = relays,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
@@ -380,24 +513,22 @@ class Account(
|
||||
fun follow(tag: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val followingUsers = contactList?.follows() ?: emptyList()
|
||||
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
|
||||
|
||||
val event = if (contactList != null) {
|
||||
ContactListEvent.create(
|
||||
followingUsers,
|
||||
followingTags.plus(tag),
|
||||
contactList.relays(),
|
||||
ContactListEvent.followHashtag(
|
||||
contactList,
|
||||
tag,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
|
||||
ContactListEvent.create(
|
||||
followingUsers,
|
||||
followingTags.plus(tag),
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = emptyList(),
|
||||
followTags = listOf(tag),
|
||||
followCommunities = emptyList(),
|
||||
followEvents = DefaultChannels.toList(),
|
||||
relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) },
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
@@ -408,15 +539,12 @@ class Account(
|
||||
fun unfollow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val followingUsers = contactList?.follows() ?: emptyList()
|
||||
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
|
||||
|
||||
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
|
||||
val event = ContactListEvent.create(
|
||||
followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
|
||||
followingTags,
|
||||
contactList.relays(),
|
||||
if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
val event = ContactListEvent.unfollowUser(
|
||||
contactList,
|
||||
user.pubkeyHex,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
@@ -428,15 +556,46 @@ class Account(
|
||||
fun unfollow(tag: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val followingUsers = contactList?.follows() ?: emptyList()
|
||||
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
|
||||
|
||||
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
|
||||
val event = ContactListEvent.create(
|
||||
followingUsers,
|
||||
followingTags.filter { !it.equals(tag, ignoreCase = true) },
|
||||
contactList.relays(),
|
||||
if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
val event = ContactListEvent.unfollowHashtag(
|
||||
contactList,
|
||||
tag,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun unfollow(channel: Channel) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
|
||||
|
||||
if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
val event = ContactListEvent.unfollowEvent(
|
||||
contactList,
|
||||
channel.idHex,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun unfollow(community: AddressableNote) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
|
||||
|
||||
if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
val event = ContactListEvent.unfollowAddressableEvent(
|
||||
contactList,
|
||||
community.address,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
@@ -462,25 +621,26 @@ class Account(
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
description = headerInfo.description,
|
||||
sensitiveContent = headerInfo.sensitiveContent,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
return Pair(data, signedEvent)
|
||||
}
|
||||
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
Client.send(data)
|
||||
Client.send(data, relayList = relayList)
|
||||
LocalCache.consume(data, null)
|
||||
|
||||
Client.send(signedEvent)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
|
||||
fun sendHeader(headerInfo: FileHeader): Note? {
|
||||
fun sendHeader(headerInfo: FileHeader, relayList: List<Relay>? = null): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
val signedEvent = FileHeaderEvent.create(
|
||||
@@ -491,10 +651,11 @@ class Account(
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
description = headerInfo.description,
|
||||
sensitiveContent = headerInfo.sensitiveContent,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(signedEvent)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
@@ -507,9 +668,11 @@ class Account(
|
||||
tags: List<String>? = null,
|
||||
zapReceiver: String? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
replyingTo: String?,
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>
|
||||
directMentions: Set<HexKey>,
|
||||
relayList: List<Relay>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -525,13 +688,14 @@ class Account(
|
||||
extraTags = tags,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
replyingTo = replyingTo,
|
||||
root = root,
|
||||
directMentions = directMentions,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(signedEvent)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
@@ -545,7 +709,9 @@ class Account(
|
||||
consensusThreshold: Int?,
|
||||
closedAt: Int?,
|
||||
zapReceiver: String? = null,
|
||||
wantsToMarkAsSensitive: Boolean
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<Relay>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -565,14 +731,15 @@ class Account(
|
||||
consensusThreshold = consensusThreshold,
|
||||
closedAt = closedAt,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount
|
||||
)
|
||||
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
|
||||
Client.send(signedEvent)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
@@ -586,13 +753,35 @@ class Account(
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
|
||||
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val repliesToHex = replyTo?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
val signedEvent = LiveActivitiesChatMessageEvent.create(
|
||||
message = message,
|
||||
activity = toChannel,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
@@ -606,6 +795,7 @@ class Account(
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
privateKey = loggedIn.privKey!!,
|
||||
advertiseNip18 = false
|
||||
)
|
||||
@@ -630,7 +820,54 @@ class Account(
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
|
||||
joinChannel(event.id)
|
||||
LocalCache.getChannelIfExists(event.id)?.let {
|
||||
follow(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val noteEvent = usersEmojiList.event
|
||||
if (noteEvent !is EmojiPackSelectionEvent) return
|
||||
val emojiListEvent = emojiList.event
|
||||
if (emojiListEvent !is EmojiPackEvent) return
|
||||
|
||||
val event = EmojiPackSelectionEvent.create(
|
||||
noteEvent.taggedAddresses().filter { it != emojiListEvent.address() },
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun addEmojiPack(usersEmojiList: Note, emojiList: Note) {
|
||||
if (!isWriteable()) return
|
||||
val emojiListEvent = emojiList.event
|
||||
if (emojiListEvent !is EmojiPackEvent) return
|
||||
|
||||
val event = if (usersEmojiList.event == null) {
|
||||
EmojiPackSelectionEvent.create(
|
||||
listOf(emojiListEvent.address()),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
val noteEvent = usersEmojiList.event
|
||||
if (noteEvent !is EmojiPackSelectionEvent) return
|
||||
|
||||
if (noteEvent.taggedAddresses().any { it == emojiListEvent.address() }) {
|
||||
return
|
||||
}
|
||||
|
||||
EmojiPackSelectionEvent.create(
|
||||
noteEvent.taggedAddresses().plus(emojiListEvent.address()),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun addPrivateBookmark(note: Note) {
|
||||
@@ -809,29 +1046,70 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun joinChannel(idHex: String) {
|
||||
followingChannels = followingChannels + idHex
|
||||
live.invalidateData()
|
||||
|
||||
saveable.invalidateData()
|
||||
fun getBlockListNote(): AddressableNote {
|
||||
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, PeopleListEvent.blockList, null)
|
||||
return LocalCache.getOrCreateAddressableNote(aTag)
|
||||
}
|
||||
|
||||
fun leaveChannel(idHex: String) {
|
||||
followingChannels = followingChannels - idHex
|
||||
live.invalidateData()
|
||||
fun getBlockList(): PeopleListEvent? {
|
||||
return getBlockListNote().event as? PeopleListEvent
|
||||
}
|
||||
|
||||
saveable.invalidateData()
|
||||
private fun migrateHiddenUsersIfNeeded(latestList: PeopleListEvent?): PeopleListEvent? {
|
||||
if (latestList == null) return latestList
|
||||
|
||||
var returningList: PeopleListEvent = latestList
|
||||
|
||||
if (hiddenUsers.isNotEmpty()) {
|
||||
returningList = PeopleListEvent.addUsers(returningList, hiddenUsers.toList(), true, loggedIn.privKey!!)
|
||||
hiddenUsers = emptySet()
|
||||
}
|
||||
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun hideUser(pubkeyHex: String) {
|
||||
hiddenUsers = hiddenUsers + pubkeyHex
|
||||
val blockList = migrateHiddenUsersIfNeeded(getBlockList())
|
||||
|
||||
val event = if (blockList != null) {
|
||||
PeopleListEvent.addUser(
|
||||
earlierVersion = blockList,
|
||||
pubKeyHex = pubkeyHex,
|
||||
isPrivate = true,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
PeopleListEvent.createListWithUser(
|
||||
name = PeopleListEvent.blockList,
|
||||
pubKeyHex = pubkeyHex,
|
||||
isPrivate = true,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun showUser(pubkeyHex: String) {
|
||||
hiddenUsers = hiddenUsers - pubkeyHex
|
||||
transientHiddenUsers = transientHiddenUsers - pubkeyHex
|
||||
val blockList = migrateHiddenUsersIfNeeded(getBlockList())
|
||||
|
||||
if (blockList != null) {
|
||||
val event = PeopleListEvent.removeUser(
|
||||
earlierVersion = blockList,
|
||||
pubKeyHex = pubkeyHex,
|
||||
isPrivate = true,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
transientHiddenUsers = (transientHiddenUsers - pubkeyHex).toImmutableSet()
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
@@ -866,6 +1144,12 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultDiscoveryFollowList(name: String) {
|
||||
defaultDiscoveryFollowList = name
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
zapAmountChoices = newAmounts
|
||||
live.invalidateData()
|
||||
@@ -912,7 +1196,53 @@ class Account(
|
||||
if (listName == GLOBAL_FOLLOWS) return null
|
||||
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingTagSet()
|
||||
|
||||
return emptySet()
|
||||
val privKey = loggedIn.privKey
|
||||
|
||||
return if (listName != null) {
|
||||
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
|
||||
val list = LocalCache.addressables[aTag]
|
||||
if (list != null) {
|
||||
val publicAddresses = list.event?.hashtags() ?: emptySet()
|
||||
val privateAddresses = privKey?.let {
|
||||
(list.event as? GeneralListEvent)?.privateHashtags(it)
|
||||
} ?: emptySet()
|
||||
|
||||
(publicAddresses + privateAddresses).toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectedCommunitiesFollowList(listName: String?): Set<String>? {
|
||||
if (listName == GLOBAL_FOLLOWS) return null
|
||||
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingCommunitiesSet()
|
||||
|
||||
val privKey = loggedIn.privKey
|
||||
|
||||
return if (listName != null) {
|
||||
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
|
||||
val list = LocalCache.addressables[aTag]
|
||||
if (list != null) {
|
||||
val publicAddresses = list.event?.taggedAddresses()?.map { it.toTag() } ?: emptySet()
|
||||
val privateAddresses = privKey?.let {
|
||||
(list.event as? GeneralListEvent)?.privateTaggedAddresses(it)?.map { it.toTag() }
|
||||
} ?: emptySet()
|
||||
|
||||
(publicAddresses + privateAddresses).toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectedChatsFollowList(): Set<String> {
|
||||
val contactList = userProfile().latestContactList
|
||||
return contactList?.taggedEvents()?.toSet() ?: DefaultChannels
|
||||
}
|
||||
|
||||
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
|
||||
@@ -933,7 +1263,7 @@ class Account(
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
|
||||
joinChannel(event.id)
|
||||
follow(channel)
|
||||
}
|
||||
|
||||
fun decryptContent(note: Note): String? {
|
||||
@@ -1081,7 +1411,11 @@ class Account(
|
||||
}
|
||||
|
||||
fun isHidden(user: User) = isHidden(user.pubkeyHex)
|
||||
fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
|
||||
fun isHidden(userHex: String): Boolean {
|
||||
val blockList = getBlockList()
|
||||
|
||||
return (blockList?.publicAndPrivateUsers(loggedIn.privKey)?.contains(userHex) ?: false) || userHex in transientHiddenUsers
|
||||
}
|
||||
|
||||
fun followingKeySet(): Set<HexKey> {
|
||||
return userProfile().cachedFollowingKeySet()
|
||||
@@ -1101,7 +1435,7 @@ class Account(
|
||||
user.countReportAuthorsBy(followingKeySet()) < 5
|
||||
}
|
||||
|
||||
fun isAcceptableDirect(note: Note): Boolean {
|
||||
private fun isAcceptableDirect(note: Note): Boolean {
|
||||
if (!warnAboutPostsWithReports) {
|
||||
return note.reportsBy(userProfile()).isEmpty()
|
||||
}
|
||||
@@ -1113,6 +1447,10 @@ class Account(
|
||||
return user.pubkeyHex in followingKeySet()
|
||||
}
|
||||
|
||||
fun isFollowing(user: HexKey): Boolean {
|
||||
return user in followingKeySet()
|
||||
}
|
||||
|
||||
fun isAcceptable(note: Note): Boolean {
|
||||
return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author
|
||||
isAcceptableDirect(note) &&
|
||||
@@ -1195,9 +1533,8 @@ class Account(
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
it.cache.spamMessages.snapshot().values.forEach {
|
||||
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
|
||||
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
|
||||
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
|
||||
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
|
||||
if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) {
|
||||
transientHiddenUsers = (transientHiddenUsers + it.pubkeyHex).toImmutableSet()
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -1207,8 +1544,10 @@ class Account(
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("Init", "Account")
|
||||
backupContactList?.let {
|
||||
println("Loading saved contacts ${it.toJson()}")
|
||||
|
||||
if (userProfile().latestContactList == null) {
|
||||
LocalCache.consume(it)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
@@ -17,7 +18,6 @@ class AntiSpamFilter {
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -44,15 +44,9 @@ class AntiSpamFilter {
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
|
||||
|
||||
// Log down offenders
|
||||
if (spamMessages.get(hash) == null) {
|
||||
spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id)))
|
||||
liveSpam.invalidateData()
|
||||
} else {
|
||||
val spammer = spamMessages.get(hash)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
logOffender(hash, event)
|
||||
|
||||
liveSpam.invalidateData()
|
||||
}
|
||||
liveSpam.invalidateData()
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -62,16 +56,31 @@ class AntiSpamFilter {
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun logOffender(hashCode: Int, event: Event) {
|
||||
if (spamMessages.get(hashCode) == null) {
|
||||
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
|
||||
} else {
|
||||
val spammer = spamMessages.get(hashCode)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
}
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
@@ -11,20 +14,100 @@ import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Stable
|
||||
class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
class PublicChatChannel(idHex: String) : Channel(idHex) {
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String {
|
||||
return info.name ?: super.toBestDisplayName()
|
||||
}
|
||||
|
||||
override fun summary(): String? {
|
||||
return info.about
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) return super.profilePicture()
|
||||
return info.picture ?: super.profilePicture()
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info.name, info.about)
|
||||
.filter { it.contains(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
|
||||
var info: LiveActivitiesEvent? = null
|
||||
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
fun address() = address
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
|
||||
this.info = channelInfo
|
||||
super.updateChannelInfo(creator, updatedAt)
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String {
|
||||
return info?.title() ?: super.toBestDisplayName()
|
||||
}
|
||||
|
||||
override fun summary(): String? {
|
||||
return info?.summary()
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
return info?.image()?.ifBlank { null }
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info?.title(), info?.summary())
|
||||
.filter { it.contains(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
abstract class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
fun idNote() = id().toNote()
|
||||
fun idDisplayNote() = idNote().toShortenHex()
|
||||
open fun id() = Hex.decode(idHex)
|
||||
open fun idNote() = id().toNote()
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return info.name ?: idDisplayNote()
|
||||
open fun toBestDisplayName(): String {
|
||||
return idDisplayNote()
|
||||
}
|
||||
|
||||
open fun summary(): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun creatorName(): String? {
|
||||
return creator?.toBestDisplayName()
|
||||
}
|
||||
|
||||
open fun profilePicture(): String? {
|
||||
return creator?.profilePicture()
|
||||
}
|
||||
|
||||
open fun updateChannelInfo(creator: User, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
@@ -39,23 +122,7 @@ class Channel(val idHex: String) {
|
||||
notes.remove(noteHex)
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) info.picture = null
|
||||
return info.picture
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info.name, info.about)
|
||||
.filter { it.startsWith(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
abstract fun anyNameStartsWith(prefix: String): Boolean
|
||||
|
||||
// Observers line up here.
|
||||
val live: ChannelLiveData = ChannelLiveData(this)
|
||||
@@ -83,7 +150,10 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
if (hasActiveObservers()) {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
@@ -92,12 +162,20 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
|
||||
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
|
||||
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
|
||||
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
|
||||
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
|
||||
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
|
||||
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class HashtagIcon(
|
||||
val icon: Int,
|
||||
val description: String,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.HexValidator
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
@@ -11,7 +13,6 @@ import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import nostr.postr.toNpub
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
@@ -32,27 +33,30 @@ object LocalCache {
|
||||
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
checkNotInMainThread()
|
||||
// checkNotInMainThread()
|
||||
|
||||
if (isValidHexNpub(key)) {
|
||||
if (isValidHex(key)) {
|
||||
return getOrCreateUser(key)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateUser(key: HexKey): User {
|
||||
// checkNotInMainThread()
|
||||
|
||||
return users[key] ?: run {
|
||||
val answer = User(key)
|
||||
users.put(key, answer)
|
||||
answer
|
||||
val newObject = User(key)
|
||||
users.putIfAbsent(key, newObject) ?: newObject
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserIfExists(key: String): User? {
|
||||
return users[key]
|
||||
if (key.isEmpty()) return null
|
||||
return users.get(key)
|
||||
}
|
||||
|
||||
fun getAddressableNoteIfExists(key: String): AddressableNote? {
|
||||
return addressables[key]
|
||||
}
|
||||
|
||||
fun getNoteIfExists(key: String): Note? {
|
||||
@@ -69,57 +73,58 @@ object LocalCache {
|
||||
if (ATag.isATag(key)) {
|
||||
return checkGetOrCreateAddressableNote(key)
|
||||
}
|
||||
if (isValidHexNpub(key)) {
|
||||
if (isValidHex(key)) {
|
||||
val note = getOrCreateNote(key)
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is AddressableEvent) {
|
||||
return if (noteEvent is AddressableEvent) {
|
||||
// upgrade to the latest
|
||||
return checkGetOrCreateAddressableNote(noteEvent.address().toTag())
|
||||
checkGetOrCreateAddressableNote(noteEvent.address().toTag())
|
||||
} else {
|
||||
return note
|
||||
note
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateNote(idHex: String): Note {
|
||||
checkNotInMainThread()
|
||||
|
||||
return notes[idHex] ?: run {
|
||||
val answer = Note(idHex)
|
||||
notes.put(idHex, answer)
|
||||
answer
|
||||
return notes.get(idHex) ?: run {
|
||||
val newObject = Note(idHex)
|
||||
notes.putIfAbsent(idHex, newObject) ?: newObject
|
||||
}
|
||||
}
|
||||
|
||||
fun checkGetOrCreateChannel(key: String): Channel? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateChannel(key)
|
||||
if (isValidHex(key)) {
|
||||
return getOrCreateChannel(key) {
|
||||
PublicChatChannel(key)
|
||||
}
|
||||
}
|
||||
val aTag = ATag.parse(key, null)
|
||||
if (aTag != null) {
|
||||
return getOrCreateChannel(aTag.toTag()) {
|
||||
LiveActivitiesChannel(aTag)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isValidHexNpub(key: String): Boolean {
|
||||
return try {
|
||||
Hex.decode(key).toNpub()
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create user: $key", e)
|
||||
false
|
||||
}
|
||||
private fun isValidHex(key: String): Boolean {
|
||||
if (key.isBlank()) return false
|
||||
if (key.contains(":")) return false
|
||||
|
||||
return HexValidator.isHex(key)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateChannel(key: String): Channel {
|
||||
fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
|
||||
checkNotInMainThread()
|
||||
|
||||
return channels[key] ?: run {
|
||||
val answer = Channel(key)
|
||||
channels.put(key, answer)
|
||||
answer
|
||||
val newObject = channelFactory(key)
|
||||
channels.putIfAbsent(key, newObject) ?: newObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,16 +142,14 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
|
||||
checkNotInMainThread()
|
||||
// checkNotInMainThread()
|
||||
|
||||
// we can't use naddr here because naddr might include relay info and
|
||||
// the preferred relay should not be part of the index.
|
||||
return addressables[key.toTag()] ?: run {
|
||||
val answer = AddressableNote(key)
|
||||
addressables.put(key.toTag(), answer)
|
||||
answer
|
||||
val newObject = AddressableNote(key)
|
||||
addressables.putIfAbsent(key.toTag(), newObject) ?: newObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +176,16 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: ContactListEvent) {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
|
||||
// avoids processing empty contact lists.
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
|
||||
user.updateContactList(event)
|
||||
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BookmarkListEvent) {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
|
||||
@@ -233,10 +246,7 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
@@ -276,8 +286,6 @@ object LocalCache {
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
@@ -305,10 +313,7 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
@@ -318,6 +323,25 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: LiveActivitiesEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
@@ -330,6 +354,68 @@ object LocalCache {
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
val channel = getOrCreateChannel(note.idHex) {
|
||||
LiveActivitiesChannel(note.address)
|
||||
} as? LiveActivitiesChannel
|
||||
channel?.updateChannelInfo(author, event, event.createdAt)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: EmojiPackSelectionEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: EmojiPackEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: ClassifiedsEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
@@ -434,7 +520,7 @@ object LocalCache {
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
author.updateAcceptedBadges(note)
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +545,26 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun comsume(event: NNSEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: AppDefinitionEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
@@ -470,7 +576,7 @@ object LocalCache {
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
@@ -504,16 +610,6 @@ object LocalCache {
|
||||
// // Log.d("RR", event.toJson())
|
||||
}
|
||||
|
||||
fun consume(event: ContactListEvent) {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
|
||||
// avoids processing empty contact lists.
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
|
||||
user.updateContactList(event)
|
||||
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PrivateDmEvent, relay: Relay?): Note {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
@@ -530,8 +626,7 @@ object LocalCache {
|
||||
|
||||
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||
|
||||
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
@@ -551,8 +646,6 @@ object LocalCache {
|
||||
event.deleteEvents().mapNotNull { notes[it] }.forEach { deleteNote ->
|
||||
// must be the same author
|
||||
if (deleteNote.author?.pubkeyHex == event.pubKey) {
|
||||
deleteNote.author?.removeNote(deleteNote)
|
||||
|
||||
// reverts the add
|
||||
val mentions = deleteNote.event?.tags()?.filter { it.firstOrNull() == "p" }
|
||||
?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) }
|
||||
@@ -572,8 +665,11 @@ object LocalCache {
|
||||
}
|
||||
|
||||
deleteNote.channelHex()?.let {
|
||||
val channel = checkGetOrCreateChannel(it)
|
||||
channel?.removeNote(deleteNote)
|
||||
channels[it]?.removeNote(deleteNote)
|
||||
}
|
||||
|
||||
(deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let {
|
||||
channels[it.toTag()]?.removeNote(deleteNote)
|
||||
}
|
||||
|
||||
if (deleteNote.event is PrivateDmEvent) {
|
||||
@@ -611,9 +707,6 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addBoost(note)
|
||||
@@ -636,8 +729,30 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addBoost(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: CommunityPostApprovalEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
val communities = event.communities()
|
||||
val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
val repliesTo = communities.map { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, eventsApproved)
|
||||
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
@@ -708,7 +823,9 @@ object LocalCache {
|
||||
|
||||
fun consume(event: ChannelCreateEvent) {
|
||||
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
|
||||
val oldChannel = getOrCreateChannel(event.id)
|
||||
val oldChannel = getOrCreateChannel(event.id) {
|
||||
PublicChatChannel(it)
|
||||
}
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
@@ -723,7 +840,9 @@ object LocalCache {
|
||||
return // older data, does nothing
|
||||
}
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
if (oldChannel is PublicChatChannel) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,10 +853,13 @@ object LocalCache {
|
||||
|
||||
// new event
|
||||
val oldChannel = checkGetOrCreateChannel(channelId) ?: return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
if (event.createdAt > oldChannel.updatedMetadataAt) {
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
if (oldChannel is PublicChatChannel) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -785,7 +907,48 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
|
||||
// Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LiveActivitiesChatMessageEvent, relay: Relay?) {
|
||||
val activityId = event.activity() ?: return
|
||||
|
||||
val channel = getOrCreateChannel(activityId.toTag()) {
|
||||
LiveActivitiesChannel(activityId)
|
||||
}
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
channel.addNote(note)
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = event.tagsWithoutCitations()
|
||||
.filter { it != event.activity()?.toTag() }
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
@@ -910,9 +1073,6 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
// Adds to user profile
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@@ -1032,8 +1192,7 @@ object LocalCache {
|
||||
it.idNote().startsWith(text, true)
|
||||
} + addressables.values.filter {
|
||||
(it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false ||
|
||||
(it.event as? LongTextNoteEvent)?.title()?.contains(text, true) ?: false ||
|
||||
(it.event as? LongTextNoteEvent)?.summary()?.contains(text, true) ?: false ||
|
||||
it.event?.matchTag1With(text) ?: false ||
|
||||
it.idHex.startsWith(text, true)
|
||||
}
|
||||
}
|
||||
@@ -1084,24 +1243,26 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
|
||||
if (toBeRemoved.size > 100 || it.value.notes.size > 100) {
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneHiddenMessages(account: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val toBeRemoved = account.hiddenUsers.map {
|
||||
(users[it]?.notes ?: emptySet())
|
||||
val toBeRemoved = account.hiddenUsers.map { userHex ->
|
||||
(
|
||||
notes.values.filter {
|
||||
it.event?.pubKey() == userHex
|
||||
} + addressables.values.filter {
|
||||
it.event?.pubKey() == userHex
|
||||
}
|
||||
).toSet()
|
||||
}.flatten()
|
||||
|
||||
account.hiddenUsers.forEach {
|
||||
users[it]?.clearNotes()
|
||||
}
|
||||
|
||||
toBeRemoved.forEach {
|
||||
it.author?.removeNote(it)
|
||||
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeReply(it)
|
||||
@@ -1164,14 +1325,25 @@ object LocalCache {
|
||||
is ChannelMessageEvent -> consume(event, relay)
|
||||
is ChannelMetadataEvent -> consume(event)
|
||||
is ChannelMuteUserEvent -> consume(event)
|
||||
is ClassifiedsEvent -> consume(event)
|
||||
is CommunityDefinitionEvent -> consume(event, relay)
|
||||
is CommunityPostApprovalEvent -> {
|
||||
event.containedPost()?.let {
|
||||
verifyAndConsume(it, relay)
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
is ContactListEvent -> consume(event)
|
||||
is DeletionEvent -> consume(event)
|
||||
is EmojiPackEvent -> consume(event)
|
||||
is EmojiPackSelectionEvent -> consume(event)
|
||||
|
||||
is FileHeaderEvent -> consume(event, relay)
|
||||
is FileStorageEvent -> consume(event, relay)
|
||||
is FileStorageHeaderEvent -> consume(event, relay)
|
||||
is HighlightEvent -> consume(event, relay)
|
||||
is LiveActivitiesEvent -> consume(event, relay)
|
||||
is LiveActivitiesChatMessageEvent -> consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
event.zapRequest?.let {
|
||||
verifyAndConsume(it, relay)
|
||||
@@ -1183,6 +1355,7 @@ object LocalCache {
|
||||
is LnZapPaymentResponseEvent -> consume(event)
|
||||
is LongTextNoteEvent -> consume(event, relay)
|
||||
is MetadataEvent -> consume(event)
|
||||
is NNSEvent -> comsume(event)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event)
|
||||
is PeopleListEvent -> consume(event)
|
||||
@@ -1214,12 +1387,13 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LocalCacheLiveData {
|
||||
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
|
||||
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
|
||||
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
|
||||
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||
|
||||
@@ -5,11 +5,13 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
@@ -19,7 +21,6 @@ import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
@@ -30,7 +31,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
override fun toNEvent() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
override fun address() = address
|
||||
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
|
||||
override fun createdAt(): Long? {
|
||||
if (event == null) return null
|
||||
|
||||
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
|
||||
val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE
|
||||
|
||||
return minOf(publishedAt, lastCreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -67,12 +75,27 @@ open class Note(val idHex: String) {
|
||||
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
|
||||
fun toNostrUri(): String {
|
||||
return "nostr:${toNEvent()}"
|
||||
}
|
||||
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun channelHex(): HexKey? {
|
||||
return (event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
return if (event is ChannelMessageEvent ||
|
||||
event is ChannelMetadataEvent ||
|
||||
event is ChannelCreateEvent ||
|
||||
event is LiveActivitiesChatMessageEvent ||
|
||||
event is LiveActivitiesEvent
|
||||
) {
|
||||
(event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
|
||||
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
@@ -135,7 +158,8 @@ open class Note(val idHex: String) {
|
||||
liveSet?.boosts?.invalidateData()
|
||||
}
|
||||
fun removeReaction(note: Note) {
|
||||
val reaction = note.event?.content() ?: "+"
|
||||
val tags = note.event?.tags() ?: emptyList()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
|
||||
reactions[reaction]?.let {
|
||||
@@ -192,31 +216,64 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZapPayment(zapPaymentRequest: Note, zapPayment: Note?): Boolean {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
val reaction = note.event?.content() ?: "+"
|
||||
val tags = note.event?.tags() ?: emptyList()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction !in reactions.keys) {
|
||||
reactions = reactions + Pair(reaction, setOf(note))
|
||||
@@ -261,10 +318,63 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): String? {
|
||||
return reactions.filter {
|
||||
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
}.keys.firstOrNull()
|
||||
fun publicZapAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.mapNotNull {
|
||||
it.key.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun publicZapAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.mapNotNull {
|
||||
it.key.author?.pubkeyHex
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun reactionAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return reactions.values.map {
|
||||
it.mapNotNull { it.author }
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
fun reactionAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return reactions.values.map {
|
||||
it.mapNotNull { it.author?.pubkeyHex }
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
fun replyAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return replies.mapNotNull {
|
||||
it.author?.pubkeyHex
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun replyAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return replies.mapNotNull {
|
||||
it.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun boostAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return boosts.mapNotNull {
|
||||
it.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun getReactionBy(user: User): String? {
|
||||
return reactions.firstNotNullOfOrNull {
|
||||
if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
|
||||
it.key
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isBoostedBy(user: User): Boolean {
|
||||
@@ -363,7 +473,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun hasAnyReports(): Boolean {
|
||||
val dayAgo = Date().time / 1000 - 24 * 60 * 60
|
||||
val dayAgo = TimeUtils.oneDayAgo()
|
||||
return reports.isNotEmpty() ||
|
||||
(
|
||||
author?.reports?.values?.any {
|
||||
@@ -373,7 +483,14 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun isNewThread(): Boolean {
|
||||
return event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo?.size == 0
|
||||
return (
|
||||
event is RepostEvent ||
|
||||
event is GenericRepostEvent ||
|
||||
replyTo == null ||
|
||||
replyTo?.size == 0
|
||||
) &&
|
||||
event !is ChannelMessageEvent &&
|
||||
event !is LiveActivitiesChatMessageEvent
|
||||
}
|
||||
|
||||
fun hasZapped(loggedIn: User): Boolean {
|
||||
@@ -385,7 +502,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User, content: String): List<Note> {
|
||||
return reactions[content]?.filter { it.author == loggedIn && it.event?.content() == content } ?: emptyList()
|
||||
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User): List<String> {
|
||||
@@ -393,8 +510,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
|
||||
val currentTime = Date().time / 1000
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5) } != null // 5 minute protection
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() } != null // 5 minute protection
|
||||
}
|
||||
|
||||
fun boostedBy(loggedIn: User): List<Note> {
|
||||
@@ -451,6 +567,22 @@ open class Note(val idHex: String) {
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
|
||||
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
|
||||
if (event == null) return false
|
||||
|
||||
val isBoostedNoteHidden = if (event is GenericRepostEvent || event is RepostEvent || event is CommunityPostApprovalEvent) {
|
||||
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val isSensitive = event?.isSensitive() ?: false
|
||||
return isBoostedNoteHidden ||
|
||||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
|
||||
accountChoices.spammers.contains(author?.pubkeyHex) ||
|
||||
(isSensitive && accountChoices.showSensitiveContent == false)
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveSet(u: Note) {
|
||||
@@ -477,13 +609,17 @@ class NoteLiveSet(u: Note) {
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
class ParticipantListBuilder {
|
||||
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
|
||||
baseNote.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
|
||||
// Breaks these searchers down to avoid the memory use of creating multiple lists
|
||||
baseNote.replies.forEach { reply ->
|
||||
reply.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.boosts.forEach { boost ->
|
||||
boost.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.zaps.forEach { zapPair ->
|
||||
zapPair.key.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.reactions.forEach { reactionSet ->
|
||||
reactionSet.value.forEach { reaction ->
|
||||
reaction.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun followsThatParticipateOnDirect(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
|
||||
if (baseNote == null) return mutableSetOf()
|
||||
|
||||
val set = mutableSetOf<User>()
|
||||
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set)
|
||||
return set
|
||||
}
|
||||
|
||||
fun followsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
|
||||
if (baseNote == null) return mutableSetOf()
|
||||
|
||||
val mySet = mutableSetOf<User>()
|
||||
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
|
||||
|
||||
baseNote.replies.forEach {
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
|
||||
baseNote.boosts.forEach {
|
||||
it.replyTo?.forEach {
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
}
|
||||
|
||||
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
|
||||
return mySet
|
||||
}
|
||||
|
||||
fun countFollowsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Int {
|
||||
if (baseNote == null) return 0
|
||||
|
||||
val list = followsThatParticipateOn(baseNote, followingSet)
|
||||
|
||||
return list.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class RelayInformation(
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
val pubkey: String?,
|
||||
val contact: String?,
|
||||
val supported_nips: List<Int>?,
|
||||
val supported_nip_extensions: List<String>?,
|
||||
val software: String?,
|
||||
val version: String?,
|
||||
val limitation: RelayInformationLimitation?,
|
||||
val relay_countries: List<String>?,
|
||||
val language_tags: List<String>?,
|
||||
val tags: List<String>?,
|
||||
val posting_policy: String?,
|
||||
val payments_url: String?,
|
||||
val fees: RelayInformationFees?
|
||||
) {
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(RelayInformation::class.java, RelayInformationSerializer())
|
||||
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
|
||||
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
|
||||
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String): RelayInformation = gson.fromJson(json, RelayInformation::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
class RelayInformationFee(
|
||||
val amount: Int?,
|
||||
val unit: String?,
|
||||
val period: Int?,
|
||||
val kinds: List<Int>?
|
||||
) {
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String): RelayInformationFee = gson.fromJson(json, RelayInformationFee::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private class RelayInformationFeeSerializer : JsonSerializer<RelayInformationFee> {
|
||||
override fun serialize(
|
||||
src: RelayInformationFee,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
addProperty("amount", src.amount)
|
||||
addProperty("unit", src.unit)
|
||||
addProperty("period", src.period)
|
||||
add(
|
||||
"kinds",
|
||||
JsonArray().also { kinds ->
|
||||
src.kinds?.forEach { kind ->
|
||||
kinds.add(
|
||||
kind
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RelayInformationFees(
|
||||
val admission: List<RelayInformationFee>?,
|
||||
val subscription: List<RelayInformationFee>?,
|
||||
val publication: List<RelayInformationFee>?
|
||||
) {
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String): RelayInformationFees = gson.fromJson(json, RelayInformationFees::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private class RelayInformationFeesSerializer : JsonSerializer<RelayInformationFees> {
|
||||
override fun serialize(
|
||||
src: RelayInformationFees,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
add(
|
||||
"admission",
|
||||
JsonArray().also { admissions ->
|
||||
src.admission?.forEach { admission ->
|
||||
admissions.add(
|
||||
admission.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"publication",
|
||||
JsonArray().also { publications ->
|
||||
src.publication?.forEach { publication ->
|
||||
publications.add(
|
||||
publication.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"subscription",
|
||||
JsonArray().also { subscriptions ->
|
||||
src.subscription?.forEach { subscription ->
|
||||
subscriptions.add(
|
||||
subscription.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RelayInformationLimitation(
|
||||
val max_message_length: Int?,
|
||||
val max_subscriptions: Int?,
|
||||
val max_filters: Int?,
|
||||
val max_limit: Int?,
|
||||
val max_subid_length: Int?,
|
||||
val min_prefix: Int?,
|
||||
val max_event_tags: Int?,
|
||||
val max_content_length: Int?,
|
||||
val min_pow_difficulty: Int?,
|
||||
val auth_required: Boolean?,
|
||||
val payment_required: Boolean?
|
||||
) {
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String): RelayInformationLimitation = gson.fromJson(json, RelayInformationLimitation::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private class RelayInformationLimitationSerializer : JsonSerializer<RelayInformationLimitation> {
|
||||
override fun serialize(
|
||||
src: RelayInformationLimitation,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
addProperty("max_message_length", src.max_message_length)
|
||||
addProperty("max_subscriptions", src.max_subscriptions)
|
||||
addProperty("max_filters", src.max_filters)
|
||||
addProperty("max_limit", src.max_limit)
|
||||
addProperty("max_subid_length", src.max_subid_length)
|
||||
addProperty("min_prefix", src.min_prefix)
|
||||
addProperty("max_event_tags", src.max_event_tags)
|
||||
addProperty("max_content_length", src.max_content_length)
|
||||
addProperty("min_pow_difficulty", src.min_pow_difficulty)
|
||||
addProperty("auth_required", src.auth_required)
|
||||
addProperty("payment_required", src.payment_required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class RelayInformationSerializer : JsonSerializer<RelayInformation> {
|
||||
override fun serialize(
|
||||
src: RelayInformation,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
addProperty("name", src.name)
|
||||
addProperty("description", src.description)
|
||||
addProperty("pubkey", src.pubkey)
|
||||
addProperty("contact", src.contact)
|
||||
add(
|
||||
"supported_nip_extensions",
|
||||
JsonArray().also { supported_nip_extensions ->
|
||||
src.supported_nip_extensions?.forEach { nip ->
|
||||
supported_nip_extensions.add(
|
||||
nip
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"supported_nips",
|
||||
JsonArray().also { supported_nips ->
|
||||
src.supported_nips?.forEach { nip ->
|
||||
supported_nips.add(
|
||||
nip
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
addProperty("software", src.software)
|
||||
addProperty("version", src.version)
|
||||
add(
|
||||
"relay_countries",
|
||||
JsonArray().also { relay_countries ->
|
||||
src.relay_countries?.forEach { country ->
|
||||
relay_countries.add(
|
||||
country
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"language_tags",
|
||||
JsonArray().also { language_tags ->
|
||||
src.language_tags?.forEach { language_tag ->
|
||||
language_tags.add(
|
||||
language_tag
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"tags",
|
||||
JsonArray().also { tags ->
|
||||
src.tags?.forEach { tag ->
|
||||
tags.add(
|
||||
tag
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
addProperty("posting_policy", src.posting_policy)
|
||||
addProperty("payments_url", src.payments_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,6 @@ data class RelaySetupInfo(
|
||||
val downloadCountInBytes: Int = 0,
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
|
||||
@Stable
|
||||
class Settings(
|
||||
var automaticallyShowImages: Boolean? = null,
|
||||
var automaticallyStartPlayback: Boolean? = null,
|
||||
var preferredLanguage: String? = null,
|
||||
var automaticallyShowUrlPreview: Boolean? = null
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import kotlin.time.ExperimentalTime
|
||||
@@ -43,16 +42,7 @@ class ThreadAssembler {
|
||||
checkNotInMainThread()
|
||||
|
||||
val (result, elapsed) = measureTimedValue {
|
||||
val note = if (noteId.contains(":")) {
|
||||
val aTag = ATag.parse(noteId, null)
|
||||
if (aTag != null) {
|
||||
LocalCache.getOrCreateAddressableNote(aTag)
|
||||
} else {
|
||||
return emptySet()
|
||||
}
|
||||
} else {
|
||||
LocalCache.getOrCreateNote(noteId)
|
||||
}
|
||||
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = mutableSetOf<Note>()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
object TimeUtils {
|
||||
const val fiveMinutes = 60 * 5
|
||||
const val oneHour = 60 * 60
|
||||
const val oneDay = 24 * 60 * 60
|
||||
|
||||
fun now() = System.currentTimeMillis() / 1000
|
||||
fun fiveMinutesAgo() = now() - fiveMinutes
|
||||
fun oneHourAgo() = now() - oneHour
|
||||
fun oneDayAgo() = now() - oneDay
|
||||
fun eightHoursAgo() = now() - (oneHour * 8)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
|
||||
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
|
||||
@Stable
|
||||
object UrlCachedPreviewer {
|
||||
var cache = LruCache<String, UrlPreviewState>(100)
|
||||
private set
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
@@ -30,9 +32,6 @@ class User(val pubkeyHex: String) {
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestBookmarkList: BookmarkListEvent? = null
|
||||
|
||||
var notes = setOf<Note>()
|
||||
private set
|
||||
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
@@ -47,13 +46,13 @@ class User(val pubkeyHex: String) {
|
||||
var privateChatrooms = mapOf<User, Chatroom>()
|
||||
private set
|
||||
|
||||
var acceptedBadges: AddressableNote? = null
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
fun toNostrUri() = "nostr:${pubkeyNpub()}"
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
@@ -105,21 +104,6 @@ class User(val pubkeyHex: String) {
|
||||
liveSet?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
if (note !in notes) {
|
||||
notes = notes + note
|
||||
// No need for Listener yet
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
notes = notes - note
|
||||
}
|
||||
|
||||
fun clearNotes() {
|
||||
notes = setOf<Note>()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
val author = note.author ?: return
|
||||
|
||||
@@ -143,11 +127,6 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAcceptedBadges(note: AddressableNote) {
|
||||
acceptedBadges = note
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
@@ -185,34 +164,34 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
|
||||
checkNotInMainThread()
|
||||
|
||||
return privateChatrooms[user] ?: run {
|
||||
val privateChatroom = Chatroom(setOf<Note>())
|
||||
val privateChatroom = Chatroom()
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
privateChatroom
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
|
||||
}
|
||||
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
|
||||
privateChatroom.removeMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -235,6 +214,7 @@ class User(val pubkeyHex: String) {
|
||||
info = newUserInfo
|
||||
info?.latestMetadata = latestMetadata
|
||||
info?.updatedMetadataAt = latestMetadata.createdAt
|
||||
info?.tags = latestMetadata.tags.toImmutableListOfLists()
|
||||
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
|
||||
try {
|
||||
@@ -297,6 +277,10 @@ class User(val pubkeyHex: String) {
|
||||
return latestContactList?.verifiedFollowTagSet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingCommunitiesSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowCommunitySet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowCount(): Int? {
|
||||
return latestContactList?.verifiedFollowKeySet?.size
|
||||
}
|
||||
@@ -347,7 +331,6 @@ class UserLiveSet(u: User) {
|
||||
val relayInfo: UserLiveData = UserLiveData(u)
|
||||
val metadata: UserLiveData = UserLiveData(u)
|
||||
val zaps: UserLiveData = UserLiveData(u)
|
||||
val badges: UserLiveData = UserLiveData(u)
|
||||
val bookmarks: UserLiveData = UserLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
@@ -359,7 +342,6 @@ class UserLiveSet(u: User) {
|
||||
relayInfo.hasObservers() ||
|
||||
metadata.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
badges.hasObservers() ||
|
||||
bookmarks.hasObservers()
|
||||
}
|
||||
}
|
||||
@@ -371,7 +353,27 @@ data class RelayInfo(
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
data class Chatroom(var roomMessages: Set<Note>)
|
||||
class Chatroom() {
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
|
||||
@Synchronized
|
||||
fun addMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserMetadata {
|
||||
@@ -399,6 +401,7 @@ class UserMetadata {
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
var tags: ImmutableListOfLists<String>? = null
|
||||
|
||||
fun anyName(): String? {
|
||||
return display_name ?: displayName ?: name ?: username
|
||||
@@ -412,14 +415,35 @@ class UserMetadata {
|
||||
fun lnAddress(): String? {
|
||||
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun bestUsername(): String? {
|
||||
return name?.ifBlank { null } ?: username?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun bestDisplayName(): String? {
|
||||
return displayName?.ifBlank { null } ?: display_name?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun nip05(): String? {
|
||||
return nip05?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (picture.isNullOrBlank()) picture = null
|
||||
return picture
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ object BlurHashDecoder {
|
||||
* By default it is true.
|
||||
*/
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
@@ -14,6 +15,7 @@ import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Stable
|
||||
class BlurHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import android.util.Patterns
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
data class RichTextViewerState(
|
||||
val urlSet: ImmutableSet<String>,
|
||||
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
|
||||
val imageList: ImmutableList<ZoomableUrlContent>,
|
||||
val customEmoji: ImmutableMap<String, String>,
|
||||
val paragraphs: ImmutableList<ParagraphState>
|
||||
)
|
||||
|
||||
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
|
||||
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(200)
|
||||
|
||||
fun parseText(content: String, tags: ImmutableListOfLists<String>): RichTextViewerState {
|
||||
return if (richTextCache[content] != null) {
|
||||
richTextCache[content]
|
||||
} else {
|
||||
val newUrls = RichTextParser().parseText(content, tags)
|
||||
richTextCache.put(content, newUrls)
|
||||
newUrls
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
|
||||
// Android9 seems to have an issue starting this regex.
|
||||
val noProtocolUrlValidator = try {
|
||||
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]*[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
|
||||
} catch (e: Exception) {
|
||||
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
}
|
||||
|
||||
class RichTextParser() {
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>
|
||||
): RichTextViewerState {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
|
||||
val urlSet = urls.mapNotNullTo(LinkedHashSet(urls.size)) {
|
||||
// removes e-mails
|
||||
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
|
||||
null
|
||||
} else if (isNumber(it.originalUrl)) {
|
||||
null
|
||||
} else if (it.originalUrl.contains("。")) {
|
||||
null
|
||||
} else {
|
||||
val pattern = "^((http|https)://)?([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
|
||||
if (pattern.matches(it.originalUrl)) {
|
||||
it.originalUrl
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlImage(fullUrl)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlVideo(fullUrl)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
val emojiMap =
|
||||
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
|
||||
|
||||
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
|
||||
|
||||
return RichTextViewerState(
|
||||
urlSet.toImmutableSet(),
|
||||
imagesForPager.toImmutableMap(),
|
||||
imageList.toImmutableList(),
|
||||
emojiMap.toImmutableMap(),
|
||||
segments
|
||||
)
|
||||
}
|
||||
|
||||
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
|
||||
var paragraphSegments = persistentListOf<ParagraphState>()
|
||||
|
||||
content.split('\n').forEach { paragraph ->
|
||||
var segments = persistentListOf<Segment>()
|
||||
var isDirty = false
|
||||
|
||||
val isRTL = isArabic(paragraph)
|
||||
|
||||
val wordList = paragraph.split(' ')
|
||||
wordList.forEach { word ->
|
||||
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
|
||||
if (wordSegment !is RegularTextSegment) {
|
||||
isDirty = true
|
||||
}
|
||||
segments = segments.add(wordSegment)
|
||||
}
|
||||
|
||||
val newSegments = if (isDirty) {
|
||||
ParagraphState(segments, isRTL)
|
||||
} else {
|
||||
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
|
||||
}
|
||||
|
||||
paragraphSegments = paragraphSegments.add(newSegments)
|
||||
}
|
||||
|
||||
return paragraphSegments
|
||||
}
|
||||
|
||||
fun isNumber(word: String): Boolean {
|
||||
return numberPattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
fun isDate(word: String): Boolean {
|
||||
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
private fun isArabic(text: String): Boolean {
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
}
|
||||
|
||||
private fun wordIdentifier(word: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): Segment {
|
||||
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
|
||||
val phoneMatcher = Patterns.PHONE.matcher(word)
|
||||
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
|
||||
|
||||
return if (word.isEmpty()) {
|
||||
RegularTextSegment(word)
|
||||
} else if (images.contains(word)) {
|
||||
ImageSegment(word)
|
||||
} else if (urls.contains(word)) {
|
||||
LinkSegment(word)
|
||||
} else if (emojis.any { word.contains(it.key) }) {
|
||||
EmojiSegment(word)
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
InvoiceSegment(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
WithdrawSegment(word)
|
||||
} else if (word.startsWith("cashuA", true)) {
|
||||
CashuSegment(word)
|
||||
} else if (emailMatcher.matches()) {
|
||||
EmailSegment(word)
|
||||
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
|
||||
PhoneSegment(word)
|
||||
} else if (startsWithNIP19Scheme(word)) {
|
||||
BechSegment(word)
|
||||
} else if (word.startsWith("#")) {
|
||||
parseHash(word, tags)
|
||||
} else if (schemelessMatcher.find()) {
|
||||
val url = schemelessMatcher.group(1) // url
|
||||
val additionalChars = schemelessMatcher.group(4) // additional chars
|
||||
val pattern = "^([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
|
||||
if (pattern.matches(word)) {
|
||||
SchemelessUrlSegment(word, url, additionalChars)
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHash(word: String, tags: ImmutableListOfLists<String>): Segment {
|
||||
// First #[n]
|
||||
|
||||
val matcher = tagIndex.matcher(word)
|
||||
try {
|
||||
if (matcher.find()) {
|
||||
val index = matcher.group(1)?.toInt()
|
||||
val suffix = matcher.group(2)
|
||||
|
||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
||||
val tag = tags.lists[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
return HashIndexUserSegment(word, tag[1], suffix)
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
return HashIndexEventSegment(word, tag[1], suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
}
|
||||
|
||||
// Second #Amethyst
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
try {
|
||||
if (hashtagMatcher.find()) {
|
||||
val hashtag = hashtagMatcher.group(1)
|
||||
if (hashtag != null) {
|
||||
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
}
|
||||
|
||||
return RegularTextSegment(word)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
|
||||
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
open class Segment(val segmentText: String)
|
||||
|
||||
@Immutable
|
||||
class ImageSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class LinkSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class EmojiSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class InvoiceSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class WithdrawSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class CashuSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class EmailSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PhoneSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class BechSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class HashIndexUserSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashIndexEventSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class RegularTextSegment(segment: String) : Segment(segment)
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.Base64
|
||||
|
||||
@Immutable
|
||||
data class CashuToken(
|
||||
val token: String,
|
||||
val mint: String,
|
||||
val totalAmount: Long,
|
||||
val fees: Int,
|
||||
val redeemInvoiceAmount: Long,
|
||||
val proofs: JsonArray
|
||||
)
|
||||
|
||||
class CashuProcessor {
|
||||
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val base64token = cashuToken.replace("cashuA", "")
|
||||
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
|
||||
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
|
||||
val proofs = token["proofs"].asJsonArray
|
||||
val mint = token["mint"].asString
|
||||
|
||||
var totalAmount = 0L
|
||||
for (proof in proofs) {
|
||||
totalAmount += proof.asJsonObject["amount"].asLong
|
||||
}
|
||||
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
|
||||
val redeemInvoiceAmount = totalAmount - fees
|
||||
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
|
||||
} catch (e: Exception) {
|
||||
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
|
||||
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
runCatching {
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
|
||||
message = "Redeem Cashu",
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, onSuccess, onError)
|
||||
},
|
||||
onProgress = {
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
|
||||
val jsonObject = JsonObject()
|
||||
jsonObject.add("proofs", token.proofs)
|
||||
jsonObject.addProperty("pr", invoice)
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val body = response.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
|
||||
val successful = tree?.get("paid")?.asText() == "true"
|
||||
|
||||
if (successful) {
|
||||
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
|
||||
} else {
|
||||
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Token melt failure: " + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,16 @@ class FileHeader(
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val description: String? = null
|
||||
val description: String? = null,
|
||||
val sensitiveContent: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
|
||||
if (imageData != null) {
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
prepare(imageData, fileUrl, mimeType, description, sensitiveContent, onReady, onError)
|
||||
} else {
|
||||
onError()
|
||||
}
|
||||
@@ -39,6 +40,7 @@ class FileHeader(
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
description: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
@@ -79,7 +81,7 @@ class FileHeader(
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description, sensitiveContent))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
object HexValidator {
|
||||
|
||||
private fun isHex2(c: Char): Boolean {
|
||||
return when (c) {
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun isHex(hex: String?): Boolean {
|
||||
if (hex == null) return false
|
||||
var isHex = true
|
||||
for (c in hex.toCharArray()) {
|
||||
if (!isHex2(c)) {
|
||||
isHex = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return isHex
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,29 @@ import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
object HttpClient {
|
||||
private var proxy: Proxy? = null
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
|
||||
if (oldValue != newValue) {
|
||||
proxyChangeListeners.forEach {
|
||||
it()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start(account: Account?) {
|
||||
this.proxy = account?.proxy
|
||||
this.internalProxy = account?.proxy
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val seconds = if (internalProxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.proxy(internalProxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
@@ -25,7 +35,7 @@ object HttpClient {
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return proxy
|
||||
return internalProxy
|
||||
}
|
||||
|
||||
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
class NIP30Parser {
|
||||
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
@@ -18,7 +20,7 @@ class NIP30Parser {
|
||||
|
||||
val regularChars = input.split(customEmojiPattern.toRegex())
|
||||
|
||||
var finalList = mutableListOf<String>()
|
||||
val finalList = mutableListOf<String>()
|
||||
var index = 0
|
||||
for (e in regularChars) {
|
||||
finalList.add(e)
|
||||
|
||||
@@ -13,14 +13,15 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class Nip05Verifier() {
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
|
||||
}
|
||||
if (parts.size == 1) {
|
||||
return "https://${parts[0]}/.well-known/nostr.json?name=_"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -33,7 +34,7 @@ class Nip05Verifier() {
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// checkNotInMainThread()
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
@@ -49,8 +50,10 @@ class Nip05Verifier() {
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
@@ -73,13 +76,15 @@ class Nip05Verifier() {
|
||||
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// check fails on tests
|
||||
// checkNotInMainThread()
|
||||
checkNotInMainThread()
|
||||
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchNip05Json(
|
||||
nip05,
|
||||
onSuccess = {
|
||||
checkNotInMainThread()
|
||||
|
||||
// NIP05 usernames are case insensitive, but JSON properties are not
|
||||
// converts the json to lowercase and then tries to access the username via a
|
||||
// lowercase version of the username.
|
||||
@@ -90,7 +95,12 @@ class Nip05Verifier() {
|
||||
null
|
||||
}
|
||||
|
||||
val user = nip05.split("@")[0].lowercase()
|
||||
val parts = nip05.split("@")
|
||||
val user = if (parts.size == 2) {
|
||||
parts[0].lowercase()
|
||||
} else {
|
||||
"_"
|
||||
}
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
kinds = listOf(BadgeProfilesEvent.kind, EmojiPackSelectionEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
limit = 10
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -80,6 +80,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountLastPostsListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 400
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
@@ -113,7 +123,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter()
|
||||
createAccountBookmarkListFilter(),
|
||||
createAccountLastPostsListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -25,7 +28,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
fun createMessagesByMeToChannelFilter(): TypedFilter? {
|
||||
val myAccount = account ?: return null
|
||||
|
||||
if (channel != null) {
|
||||
if (channel is PublicChatChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
@@ -37,12 +40,24 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
|
||||
authors = listOf(myAccount.userProfile().pubkeyHex),
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel != null) {
|
||||
if (channel is PublicChatChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
@@ -51,6 +66,15 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
|
||||
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -44,17 +44,23 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
)
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList(),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
fun createMyChannelsFilter(): TypedFilter {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = followingEvents.toList(),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
@@ -66,15 +72,17 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
|
||||
limit = 25 // Remember to consider spam that is being removed from the UI
|
||||
limit = 50 // Remember to consider spam that is being removed from the UI
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
|
||||
private var communityToWatch: AddressableNote? = null
|
||||
|
||||
private fun createLoadCommunityFilter(): TypedFilter? {
|
||||
val myCommunityToWatch = communityToWatch ?: return null
|
||||
|
||||
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
authors = community.moderators().map { it.key }.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
|
||||
tags = mapOf(
|
||||
"a" to listOf(myCommunityToWatch.address.toTag())
|
||||
),
|
||||
kinds = listOf(CommunityPostApprovalEvent.kind),
|
||||
limit = 500
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadCommunityChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadCommunity(note: AddressableNote?) {
|
||||
communityToWatch = note
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
@@ -12,7 +13,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlin.Error
|
||||
|
||||
@@ -57,7 +57,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(Date().time / 1000, relay.url)
|
||||
subscriptions[channel]?.updateEOSE(TimeUtils.now(), relay.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("Init", "${this.javaClass.simpleName} Subscribe")
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createLiveStreamFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatsTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val discoveryFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters = listOfNotNull(
|
||||
createLiveStreamFilter(),
|
||||
createPublicChatFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createLiveStreamTagsFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createCommunitiesTagsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
val globalFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
@@ -49,13 +53,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
|
||||
|
||||
val followKeys = follows.map {
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
@@ -64,11 +68,14 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
TextNoteEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ClassifiedsEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind
|
||||
PinListEvent.kind,
|
||||
LiveActivitiesChatMessageEvent.kind,
|
||||
LiveActivitiesEvent.kind
|
||||
),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
@@ -85,7 +92,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
@@ -97,11 +104,37 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowCommunitiesFilter(): TypedFilter? {
|
||||
val communitiesToLoad = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
if (communitiesToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
ClassifiedsEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind,
|
||||
CommunityPostApprovalEvent.kind
|
||||
),
|
||||
tags = mapOf(
|
||||
"a" to communitiesToLoad.toList()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowCommunitiesFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
@@ -30,8 +32,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.map { LocalCache.getOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() }
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() && it is PublicChatChannel }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
@@ -49,13 +51,43 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filterIsInstance<LiveActivitiesChannel>()
|
||||
.filter { it.info == null }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return directEventsToLoad.map {
|
||||
it.address().let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
|
||||
|
||||
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
|
||||
singleChannelChannel.typedFilters = (
|
||||
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
|
||||
@@ -33,7 +33,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
PeopleListEvent.kind, BookmarkListEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
since = it.lastReactionsDownloadTime,
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -49,14 +50,24 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex.substring(0, 8))
|
||||
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
authors = listOf(aTag.pubKeyHex)
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +98,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
PinListEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
since = it.lastReactionsDownloadTime,
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -122,6 +134,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel { time, relayUrl ->
|
||||
checkNotInMainThread()
|
||||
|
||||
eventsToWatch.forEach {
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
@@ -159,16 +173,16 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
fun addAddress(aTag: Note) {
|
||||
if (!addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
fun addAddress(addressableNote: Note) {
|
||||
if (!addressesToWatch.contains(addressableNote)) {
|
||||
addressesToWatch = addressesToWatch.plus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAddress(aTag: Note) {
|
||||
if (addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
fun removeAddress(addressableNote: Note) {
|
||||
if (addressesToWatch.contains(addressableNote)) {
|
||||
addressesToWatch = addressesToWatch.minus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -10,6 +11,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
@@ -22,7 +25,8 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200
|
||||
limit = 200,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -41,12 +45,15 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
val videoFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Request
|
||||
|
||||
@Immutable
|
||||
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
|
||||
|
||||
object OnlineChecker {
|
||||
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
|
||||
val fiveMinutes = 1000 * 60 * 5
|
||||
|
||||
fun isOnline(url: String?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (url.isNullOrBlank()) return false
|
||||
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
|
||||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
|
||||
return try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
|
||||
Log.e("LiveActivities", "Failed to check streaming url $url", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
|
||||
fun String.isUTF16Char(pos: Int): Boolean {
|
||||
println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
|
||||
return Character.charCount(this.codePointAt(pos)) == 2
|
||||
}
|
||||
|
||||
fun String.firstFullCharOld(): String {
|
||||
return when (this.length) {
|
||||
0, 1 -> return this
|
||||
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
|
||||
else -> {
|
||||
val first = isUTF16Char(0)
|
||||
val second = isUTF16Char(2)
|
||||
if (first && second) {
|
||||
this.take(4)
|
||||
} else if (first) {
|
||||
this.take(2)
|
||||
} else {
|
||||
this.take(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.firstFullChar(): String {
|
||||
var isInJoin = false
|
||||
var hasHadSecondChance = false
|
||||
var start = 0
|
||||
var previousCharLength = 0
|
||||
var next: Int
|
||||
var codePoint: Int
|
||||
|
||||
var i = 0
|
||||
|
||||
while (i < this.length) {
|
||||
codePoint = codePointAt(i)
|
||||
|
||||
// Skips if it starts with the join char 0x200D
|
||||
if (codePoint == 0x200D && previousCharLength == 0) {
|
||||
next = offsetByCodePoints(i, 1)
|
||||
start = next
|
||||
} else {
|
||||
// If join, searches for the next char
|
||||
if (codePoint == 0xFE0F) {
|
||||
} else if (codePoint == 0x200D) {
|
||||
isInJoin = true
|
||||
} else {
|
||||
// stops when two chars are not joined together
|
||||
if (previousCharLength > 0 && !isInJoin) {
|
||||
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
|
||||
break
|
||||
} else {
|
||||
hasHadSecondChance = true
|
||||
}
|
||||
} else {
|
||||
hasHadSecondChance = false
|
||||
}
|
||||
|
||||
isInJoin = false
|
||||
}
|
||||
|
||||
// next char to evaluate
|
||||
next = offsetByCodePoints(i, 1)
|
||||
previousCharLength += (next - i)
|
||||
}
|
||||
|
||||
i = next
|
||||
}
|
||||
|
||||
// if ends in join, then seachers backwards until a char is found.
|
||||
if (isInJoin) {
|
||||
i = previousCharLength - 1
|
||||
while (i > 0) {
|
||||
if (this[i].code == 0x200D) {
|
||||
previousCharLength -= 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
i -= 1
|
||||
}
|
||||
}
|
||||
|
||||
return substring(start, start + previousCharLength)
|
||||
}
|
||||
|
||||
fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
|
||||
if (length <= 2) {
|
||||
return firstFullChar()
|
||||
}
|
||||
|
||||
if (this[0] == ':') {
|
||||
// makes sure an emoji exists
|
||||
val emojiParts = this.split(":", limit = 3)
|
||||
if (emojiParts.size >= 2) {
|
||||
val emojiName = emojiParts[1]
|
||||
val emojiUrl = tags.lists.firstOrNull() { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
|
||||
if (emojiUrl != null) {
|
||||
return ":$emojiName:$emojiUrl"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstFullChar()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.service.connectivitystatus
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
||||
object ConnectivityStatus {
|
||||
private val onMobileData = mutableStateOf(false)
|
||||
val isOnMobileData: MutableState<Boolean> = onMobileData
|
||||
|
||||
private val onWifi = mutableStateOf(false)
|
||||
val isOnWifi: MutableState<Boolean> = onWifi
|
||||
|
||||
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
|
||||
if (onMobileData.value != isOnMobileData) {
|
||||
onMobileData.value = isOnMobileData
|
||||
}
|
||||
|
||||
if (onWifi.value != isOnWifi) {
|
||||
onWifi.value = isOnWifi
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,28 +55,32 @@ class LightningAddressResolver() {
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +176,7 @@ class LightningAddressResolver() {
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.let { pr ->
|
||||
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
|
||||
|
||||
@@ -103,7 +103,7 @@ object LnInvoiceUtil {
|
||||
try {
|
||||
decodeUnlimitedLength(invoice) // checksum must match
|
||||
} catch (e: AddressFormatException) {
|
||||
throw IllegalArgumentException("Cannot decode invoice", e)
|
||||
throw IllegalArgumentException("Cannot decode invoice: $invoice", e)
|
||||
}
|
||||
|
||||
val matcher = invoicePattern.matcher(invoice)
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class AudioTrackEvent(
|
||||
@@ -42,7 +42,7 @@ class AudioTrackEvent(
|
||||
cover: String? = null,
|
||||
subject: String? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): AudioTrackEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(MEDIA, media),
|
||||
|
||||
@@ -25,5 +25,6 @@ class BadgeProfilesEvent(
|
||||
|
||||
companion object {
|
||||
const val kind = 30008
|
||||
const val standardDTAg = "profile_badges"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ open class BaseTextNoteEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun mentions() = taggedUsers()
|
||||
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
open fun replyTos() = taggedEvents()
|
||||
|
||||
private var citedUsersCache: Set<HexKey>? = null
|
||||
private var citedNotesCache: Set<HexKey>? = null
|
||||
@@ -113,7 +113,7 @@ open class BaseTextNoteEvent(
|
||||
|
||||
fun tagsWithoutCitations(): List<String> {
|
||||
val repliesTo = replyTos()
|
||||
val tagAddresses = taggedAddresses().map { it.toTag() }
|
||||
val tagAddresses = taggedAddresses().filter { it.kind != CommunityDefinitionEvent.kind }.map { it.toTag() }
|
||||
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
|
||||
|
||||
val citations = findCitations()
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class BookmarkListEvent(
|
||||
@@ -30,7 +30,7 @@ class BookmarkListEvent(
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): BookmarkListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
@@ -3,9 +3,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelCreateEvent(
|
||||
@@ -26,7 +26,7 @@ class ChannelCreateEvent(
|
||||
companion object {
|
||||
const val kind = 40
|
||||
|
||||
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
|
||||
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelCreateEvent {
|
||||
val content = try {
|
||||
if (channelInfo != null) {
|
||||
gson.toJson(channelInfo)
|
||||
@@ -46,5 +46,6 @@ class ChannelCreateEvent(
|
||||
}
|
||||
}
|
||||
|
||||
data class ChannelData(var name: String?, var about: String?, var picture: String?)
|
||||
@Immutable
|
||||
data class ChannelData(val name: String?, val about: String?, val picture: String?)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelHideMessageEvent(
|
||||
@@ -14,13 +14,19 @@ class ChannelHideMessageEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
|
||||
override fun channel() = tags.firstOrNull {
|
||||
it.size > 3 && it[0] == "e" && it[3] == "root"
|
||||
}?.get(1) ?: tags.firstOrNull {
|
||||
it.size > 1 && it[0] == "e"
|
||||
}?.get(1)
|
||||
|
||||
fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 43
|
||||
|
||||
fun create(reason: String, messagesToHide: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
|
||||
fun create(reason: String, messagesToHide: List<String>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelHideMessageEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags =
|
||||
messagesToHide?.map {
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelMessageEvent(
|
||||
@@ -14,9 +14,9 @@ class ChannelMessageEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
|
||||
|
||||
fun channel() = tags.firstOrNull {
|
||||
override fun channel() = tags.firstOrNull {
|
||||
it.size > 3 && it[0] == "e" && it[3] == "root"
|
||||
}?.get(1) ?: tags.firstOrNull {
|
||||
it.size > 1 && it[0] == "e"
|
||||
@@ -34,8 +34,9 @@ class ChannelMessageEvent(
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
markAsSensitive: Boolean
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?
|
||||
): ChannelMessageEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
@@ -54,6 +55,9 @@ class ChannelMessageEvent(
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
@@ -61,3 +65,7 @@ class ChannelMessageEvent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IsInPublicChatChannel {
|
||||
open fun channel(): String?
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelMetadataEvent(
|
||||
@@ -15,8 +15,9 @@ class ChannelMetadataEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
|
||||
|
||||
override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
fun channelInfo() =
|
||||
try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
|
||||
@@ -28,7 +29,7 @@ class ChannelMetadataEvent(
|
||||
companion object {
|
||||
const val kind = 41
|
||||
|
||||
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
|
||||
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelMetadataEvent {
|
||||
val content =
|
||||
if (newChannelInfo != null) {
|
||||
gson.toJson(newChannelInfo)
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelMuteUserEvent(
|
||||
@@ -14,14 +14,19 @@ class ChannelMuteUserEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
|
||||
override fun channel() = tags.firstOrNull {
|
||||
it.size > 3 && it[0] == "e" && it[3] == "root"
|
||||
}?.get(1) ?: tags.firstOrNull {
|
||||
it.size > 1 && it[0] == "e"
|
||||
}?.get(1)
|
||||
|
||||
fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 44
|
||||
|
||||
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
|
||||
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelMuteUserEvent {
|
||||
val content = reason
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags =
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class ClassifiedsEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1)
|
||||
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
|
||||
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
|
||||
fun price() = tags.firstOrNull { it.size > 1 && it[0] == "price" }?.let {
|
||||
Price(it[1], it.getOrNull(2), it.getOrNull(3))
|
||||
}
|
||||
fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1)
|
||||
|
||||
fun publishedAt() = try {
|
||||
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 30402
|
||||
|
||||
fun create(
|
||||
dTag: String,
|
||||
title: String?,
|
||||
image: String?,
|
||||
summary: String?,
|
||||
price: Price?,
|
||||
location: String?,
|
||||
publishedAt: Long?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): ClassifiedsEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
|
||||
tags.add(listOf("d", dTag))
|
||||
title?.let { tags.add(listOf("title", it)) }
|
||||
image?.let { tags.add(listOf("image", it)) }
|
||||
summary?.let { tags.add(listOf("summary", it)) }
|
||||
price?.let {
|
||||
if (it.frequency != null && it.currency != null) {
|
||||
tags.add(listOf("price", it.amount, it.currency, it.frequency))
|
||||
} else if (it.currency != null) {
|
||||
tags.add(listOf("price", it.amount, it.currency))
|
||||
} else {
|
||||
tags.add(listOf("price", it.amount))
|
||||
}
|
||||
}
|
||||
location?.let { tags.add(listOf("location", it)) }
|
||||
publishedAt?.let { tags.add(listOf("publishedAt", it.toString())) }
|
||||
title?.let { tags.add(listOf("title", it)) }
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ClassifiedsEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Price(val amount: String, val currency: String?, val frequency: String?)
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class CommunityDefinitionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
|
||||
fun rules() = tags.firstOrNull { it.size > 1 && it[0] == "rules" }?.get(1)
|
||||
|
||||
fun moderators() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) }
|
||||
|
||||
companion object {
|
||||
const val kind = 34550
|
||||
|
||||
fun create(
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): CommunityDefinitionEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return CommunityDefinitionEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class CommunityPostApprovalEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun containedPost(): Event? = try {
|
||||
content.ifBlank { null }?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("CommunityPostEvent", "Failed to Parse Community Approval Contained Post of $id with $content")
|
||||
null
|
||||
}
|
||||
|
||||
fun communities() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
val aTag = ATag.parse(it[1], it.getOrNull(2))
|
||||
|
||||
if (aTag?.kind == CommunityDefinitionEvent.kind) {
|
||||
aTag
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun approvedEvents() = tags.filter {
|
||||
it.size > 1 && (it[0] == "e" || (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.kind))
|
||||
}.map {
|
||||
it[1]
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 4550
|
||||
|
||||
fun create(approvedPost: Event, community: CommunityDefinitionEvent, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): GenericRepostEvent {
|
||||
val content = approvedPost.toJson()
|
||||
|
||||
val communities = listOf("a", community.address().toTag())
|
||||
val replyToPost = listOf("e", approvedPost.id())
|
||||
val replyToAuthor = listOf("p", approvedPost.pubKey())
|
||||
val kind = listOf("k", "${approvedPost.kind()}")
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags: List<List<String>> = listOf(communities, replyToPost, replyToAuthor, kind)
|
||||
val id = generateId(pubKey, createdAt, GenericRepostEvent.kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return GenericRepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,18 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
data class Contact(val pubKeyHex: String, val relayUri: String?)
|
||||
|
||||
@Stable
|
||||
class ContactListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
@@ -37,6 +39,10 @@ class ContactListEvent(
|
||||
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
|
||||
}
|
||||
|
||||
val verifiedFollowCommunitySet: Set<String> by lazy {
|
||||
unverifiedFollowAddressSet().toSet()
|
||||
}
|
||||
|
||||
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
|
||||
verifiedFollowKeySet + pubKey
|
||||
}
|
||||
@@ -44,6 +50,8 @@ class ContactListEvent(
|
||||
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun unverifiedFollowAddressSet() = tags.filter { it[0] == "a" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
|
||||
try {
|
||||
Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2))
|
||||
@@ -71,14 +79,22 @@ class ContactListEvent(
|
||||
companion object {
|
||||
const val kind = 3
|
||||
|
||||
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
|
||||
fun createFromScratch(
|
||||
followUsers: List<Contact>,
|
||||
followTags: List<String>,
|
||||
followCommunities: List<ATag>,
|
||||
followEvents: List<String>,
|
||||
relayUse: Map<String, ReadWrite>?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): ContactListEvent {
|
||||
val content = if (relayUse != null) {
|
||||
gson.toJson(relayUse)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = follows.map {
|
||||
|
||||
val tags = followUsers.map {
|
||||
if (it.relayUri != null) {
|
||||
listOf("p", it.pubKeyHex, it.relayUri)
|
||||
} else {
|
||||
@@ -86,8 +102,129 @@ class ContactListEvent(
|
||||
}
|
||||
} + followTags.map {
|
||||
listOf("t", it)
|
||||
} + followEvents.map {
|
||||
listOf("e", it)
|
||||
} + followCommunities.map {
|
||||
if (it.relay != null) {
|
||||
listOf("a", it.toTag(), it.relay)
|
||||
} else {
|
||||
listOf("a", it.toTag())
|
||||
}
|
||||
}
|
||||
|
||||
return create(
|
||||
content = content,
|
||||
tags = tags,
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun followUser(earlierVersion: ContactListEvent, pubKeyHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
if (earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion
|
||||
|
||||
return create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.plus(element = listOf("p", pubKeyHex)),
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun unfollowUser(earlierVersion: ContactListEvent, pubKeyHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
if (!earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion
|
||||
|
||||
return create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex },
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun followHashtag(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
if (earlierVersion.isTaggedHash(hashtag)) return earlierVersion
|
||||
|
||||
return create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.plus(element = listOf("t", hashtag)),
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun unfollowHashtag(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
if (!earlierVersion.isTaggedHash(hashtag)) return earlierVersion
|
||||
|
||||
return create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag },
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun followEvent(earlierVersion: ContactListEvent, idHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
if (earlierVersion.isTaggedEvent(idHex)) return earlierVersion
|
||||
|
||||
return create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.plus(element = listOf("e", idHex)),
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun unfollowEvent(earlierVersion: ContactListEvent, idHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
if (!earlierVersion.isTaggedEvent(idHex)) return earlierVersion
|
||||
|
||||
return create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != idHex },
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun followAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return earlierVersion
|
||||
|
||||
return create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.plus(element = listOfNotNull("a", aTag.toTag(), aTag.relay)),
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun unfollowAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return earlierVersion
|
||||
|
||||
return create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() },
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun updateRelayList(earlierVersion: ContactListEvent, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
val content = if (relayUse != null) {
|
||||
gson.toJson(relayUse)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return create(
|
||||
content = content,
|
||||
tags = earlierVersion.tags,
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun create(content: String, tags: List<List<String>>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class DeletionEvent(
|
||||
@@ -20,7 +20,7 @@ class DeletionEvent(
|
||||
companion object {
|
||||
const val kind = 5
|
||||
|
||||
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): DeletionEvent {
|
||||
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): DeletionEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = deleteEvents.map { listOf("e", it) }
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class EmojiPackEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
companion object {
|
||||
const val kind = 30030
|
||||
|
||||
fun create(
|
||||
name: String = "",
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): EmojiPackEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return EmojiPackEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class EmojiUrl(val code: String, val url: String) {
|
||||
fun encode(): String {
|
||||
return ":$code:$url"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun decode(encodedEmojiSetup: String): EmojiUrl? {
|
||||
val emojiParts = encodedEmojiSetup.split(":", limit = 3)
|
||||
return if (emojiParts.size > 2) {
|
||||
EmojiUrl(emojiParts[1], emojiParts[2])
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class EmojiPackSelectionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
companion object {
|
||||
const val kind = 10030
|
||||
|
||||
fun create(
|
||||
listOfEmojiPacks: List<ATag>?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): EmojiPackSelectionEvent {
|
||||
val msg = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
|
||||
listOfEmojiPacks?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return EmojiPackSelectionEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.*
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import fr.acinq.secp256k1.Secp256k1
|
||||
import nostr.postr.Utils
|
||||
@@ -43,10 +45,12 @@ open class Event(
|
||||
|
||||
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
|
||||
|
||||
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
|
||||
override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
|
||||
|
||||
override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) }
|
||||
|
||||
override fun isSensitive() = tags.any {
|
||||
(it.size > 0 && it[0].equals("content-warning", true)) ||
|
||||
@@ -54,9 +58,13 @@ open class Event(
|
||||
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
|
||||
}
|
||||
|
||||
override fun zapraiserAmount() = tags.firstOrNull() {
|
||||
(it.size > 1 && it[0] == "zapraiser")
|
||||
}?.get(1)?.toLongOrNull()
|
||||
|
||||
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
override fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
val aTagValue = it[1]
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
@@ -65,12 +73,37 @@ open class Event(
|
||||
|
||||
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
|
||||
|
||||
override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) }
|
||||
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
|
||||
|
||||
override fun isTaggedEvent(idHex: String) = tags.any { it.size > 1 && it[0] == "e" && it[1] == idHex }
|
||||
|
||||
override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
|
||||
|
||||
override fun isTaggedAddressableNotes(idHexes: Set<String>) = tags.any { it.size > 1 && it[0] == "a" && it[1] in idHexes }
|
||||
|
||||
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
|
||||
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
|
||||
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
|
||||
|
||||
override fun firstIsTaggedAddressableNote(addressableNotes: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1)
|
||||
|
||||
override fun isTaggedAddressableKind(kind: Int): Boolean {
|
||||
val kindStr = kind.toString()
|
||||
return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
|
||||
}
|
||||
|
||||
override fun getTagOfAddressableKind(kind: Int): ATag? {
|
||||
val kindStr = kind.toString()
|
||||
val aTag = tags
|
||||
.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
|
||||
?.getOrNull(1)
|
||||
?: return null
|
||||
|
||||
return ATag.parse(aTag, null)
|
||||
}
|
||||
|
||||
override fun getPoWRank(): Int {
|
||||
var rank = 0
|
||||
for (i in 0..id.length) {
|
||||
@@ -100,6 +133,18 @@ open class Event(
|
||||
}
|
||||
}
|
||||
|
||||
open fun toNIP19(): String {
|
||||
return if (this is AddressableEvent) {
|
||||
ATag(kind, pubKey, dTag(), null).toNAddr()
|
||||
} else {
|
||||
Nip19.createNEvent(id, pubKey, kind, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun toNostrUri(): String {
|
||||
return "nostr:${toNIP19()}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||
*/
|
||||
@@ -239,8 +284,13 @@ open class Event(
|
||||
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ClassifiedsEvent.kind -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
EmojiPackEvent.kind -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
EmojiPackSelectionEvent.kind -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -248,12 +298,14 @@ open class Event(
|
||||
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
NNSEvent.kind -> NNSEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -288,7 +340,7 @@ open class Event(
|
||||
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
|
||||
}
|
||||
|
||||
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
|
||||
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = TimeUtils.now()): Event {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey).toHexKey()
|
||||
|
||||
@@ -28,9 +28,19 @@ interface EventInterface {
|
||||
|
||||
fun isTaggedUser(idHex: String): Boolean
|
||||
|
||||
fun isTaggedEvent(idHex: String): Boolean
|
||||
|
||||
fun isTaggedAddressableNote(idHex: String): Boolean
|
||||
fun isTaggedAddressableNotes(idHexes: Set<String>): Boolean
|
||||
|
||||
fun isTaggedHash(hashtag: String): Boolean
|
||||
fun isTaggedHashes(hashtags: Set<String>): Boolean
|
||||
fun firstIsTaggedHashes(hashtags: Set<String>): String?
|
||||
fun firstIsTaggedAddressableNote(addressableNotes: Set<String>): String?
|
||||
|
||||
fun isTaggedAddressableKind(kind: Int): Boolean
|
||||
fun getTagOfAddressableKind(kind: Int): ATag?
|
||||
|
||||
fun hashtags(): List<String>
|
||||
|
||||
fun getReward(): BigDecimal?
|
||||
@@ -38,4 +48,13 @@ interface EventInterface {
|
||||
|
||||
fun zapAddress(): String?
|
||||
fun isSensitive(): Boolean
|
||||
fun zapraiserAmount(): Long?
|
||||
|
||||
fun taggedAddresses(): List<ATag>
|
||||
fun taggedUsers(): List<HexKey>
|
||||
fun taggedEvents(): List<HexKey>
|
||||
fun taggedUrls(): List<String>
|
||||
|
||||
fun taggedEmojis(): List<EmojiUrl>
|
||||
fun matchTag1With(text: String): Boolean
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class FileHeaderEvent(
|
||||
@@ -50,8 +50,9 @@ class FileHeaderEvent(
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
encryptionKey: AESGCM? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): FileHeaderEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(URL, url),
|
||||
@@ -62,7 +63,14 @@ class FileHeaderEvent(
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
|
||||
sensitiveContent?.let {
|
||||
if (it) {
|
||||
listOf("content-warning", "")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val content = description ?: ""
|
||||
|
||||
@@ -3,10 +3,10 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Base64
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class FileStorageEvent(
|
||||
@@ -44,7 +44,7 @@ class FileStorageEvent(
|
||||
mimeType: String,
|
||||
data: ByteArray,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): FileStorageEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(TYPE, mimeType)
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class FileStorageHeaderEvent(
|
||||
@@ -50,8 +50,9 @@ class FileStorageHeaderEvent(
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
encryptionKey: AESGCM? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): FileStorageHeaderEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf("e", storageEvent.id),
|
||||
@@ -62,7 +63,14 @@ class FileStorageHeaderEvent(
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
|
||||
sensitiveContent?.let {
|
||||
if (it) {
|
||||
listOf("content-warning", "")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val content = description ?: ""
|
||||
|
||||
@@ -54,7 +54,13 @@ abstract class GeneralListEvent(
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTagsOrEmpty(privKey: ByteArray): List<List<String>> {
|
||||
return privateTags(privKey) ?: emptyList()
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
|
||||
|
||||
fun privateHashtags(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "t" }?.map { it[1] }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
@@ -90,5 +96,16 @@ abstract class GeneralListEvent(
|
||||
pubKey
|
||||
)
|
||||
}
|
||||
|
||||
fun encryptTags(
|
||||
privateTags: List<List<String>>? = null,
|
||||
privateKey: ByteArray
|
||||
): String {
|
||||
return Utils.encrypt(
|
||||
msg = gson.toJson(privateTags),
|
||||
privateKey = privateKey,
|
||||
pubKey = Utils.pubkeyCreate(privateKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class GenericRepostEvent(
|
||||
@@ -29,7 +29,7 @@ class GenericRepostEvent(
|
||||
companion object {
|
||||
const val kind = 16
|
||||
|
||||
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): GenericRepostEvent {
|
||||
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): GenericRepostEvent {
|
||||
val content = boostedPost.toJson()
|
||||
|
||||
val replyToPost = listOf("e", boostedPost.id())
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.security.MessageDigest
|
||||
|
||||
@Immutable
|
||||
class HTTPAuthorizationEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
companion object {
|
||||
const val kind = 27235
|
||||
|
||||
fun create(
|
||||
url: String,
|
||||
method: String,
|
||||
body: String? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): HTTPAuthorizationEvent {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
var hash = ""
|
||||
body?.let {
|
||||
hash = sha256.digest(it.toByteArray()).toHexKey()
|
||||
}
|
||||
|
||||
val tags = listOfNotNull(
|
||||
listOf("u", url),
|
||||
listOf("method", method),
|
||||
listOf("payload", hash)
|
||||
)
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return HTTPAuthorizationEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class HighlightEvent(
|
||||
@@ -20,13 +20,15 @@ class HighlightEvent(
|
||||
fun author() = taggedUsers().firstOrNull()
|
||||
fun quote() = content
|
||||
|
||||
fun inPost() = taggedAddresses().firstOrNull()
|
||||
|
||||
companion object {
|
||||
const val kind = 9802
|
||||
|
||||
fun create(
|
||||
msg: String,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): PollNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class LiveActivitiesChatMessageEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
private fun innerActivity() = tags.firstOrNull {
|
||||
it.size > 3 && it[0] == "a" && it[3] == "root"
|
||||
} ?: tags.firstOrNull {
|
||||
it.size > 1 && it[0] == "a"
|
||||
}
|
||||
|
||||
private fun activityHex() = innerActivity()?.let {
|
||||
it.getOrNull(1)
|
||||
}
|
||||
|
||||
fun activity() = innerActivity()?.let {
|
||||
if (it.size > 1) {
|
||||
val aTagValue = it[1]
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
ATag.parse(aTagValue, relay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun replyTos() = taggedEvents().minus(activityHex() ?: "")
|
||||
|
||||
companion object {
|
||||
const val kind = 1311
|
||||
|
||||
fun create(
|
||||
message: String,
|
||||
activity: ATag,
|
||||
replyTos: List<String>? = null,
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?
|
||||
): LiveActivitiesChatMessageEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf(
|
||||
listOf("a", activity.toTag(), "", "root")
|
||||
)
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LiveActivitiesChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class LiveActivitiesEvent(
|
||||
@@ -23,20 +23,32 @@ class LiveActivitiesEvent(
|
||||
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
|
||||
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
|
||||
fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1)
|
||||
fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)
|
||||
fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull()
|
||||
fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1)
|
||||
fun status() = tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)
|
||||
fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1))
|
||||
fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1)
|
||||
fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1)
|
||||
|
||||
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
|
||||
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) }
|
||||
|
||||
fun checkStatus(eventStatus: String?): String? {
|
||||
return if (eventStatus == STATUS_LIVE && createdAt < TimeUtils.eightHoursAgo()) {
|
||||
STATUS_ENDED
|
||||
} else {
|
||||
eventStatus
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 30311
|
||||
|
||||
const val STATUS_LIVE = "live"
|
||||
const val STATUS_PLANNED = "planned"
|
||||
const val STATUS_ENDED = "ended"
|
||||
|
||||
fun create(
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): LiveActivitiesEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
@@ -14,8 +14,6 @@ interface LnZapEventInterface : EventInterface {
|
||||
|
||||
fun zappedRequestAuthor(): String?
|
||||
|
||||
fun taggedAddresses(): List<ATag>
|
||||
|
||||
fun amount(): BigDecimal?
|
||||
|
||||
fun containedPost(): Event?
|
||||
|
||||
@@ -7,11 +7,11 @@ import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class LnZapPaymentRequestEvent(
|
||||
@@ -57,7 +57,7 @@ class LnZapPaymentRequestEvent(
|
||||
lnInvoice: String,
|
||||
walletServicePubkey: String,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): LnZapPaymentRequestEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
|
||||
|
||||
@@ -64,7 +64,7 @@ class LnZapRequestEvent(
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): LnZapRequestEvent {
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
@@ -104,7 +104,7 @@ class LnZapRequestEvent(
|
||||
privateKey: ByteArray,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): LnZapRequestEvent {
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class LongTextNoteEvent(
|
||||
@@ -24,7 +24,7 @@ class LongTextNoteEvent(
|
||||
fun summary() = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
|
||||
fun publishedAt() = try {
|
||||
tags.filter { it.firstOrNull() == "published_at" }.mapNotNull { it.getOrNull(1) }.firstOrNull()?.toLong()
|
||||
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class LongTextNoteEvent(
|
||||
companion object {
|
||||
const val kind = 30023
|
||||
|
||||
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LongTextNoteEvent {
|
||||
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): LongTextNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
replyTos?.forEach {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.Gson
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.Date
|
||||
|
||||
@Stable
|
||||
abstract class IdentityClaim(
|
||||
var identity: String,
|
||||
var proof: String
|
||||
val identity: String,
|
||||
val proof: String
|
||||
) {
|
||||
abstract fun toProofUrl(): String
|
||||
abstract fun toIcon(): Int
|
||||
@@ -145,8 +147,8 @@ class MetadataEvent(
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
|
||||
// e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error: ${e.localizedMessage} $content")
|
||||
null
|
||||
}
|
||||
|
||||
@@ -169,7 +171,7 @@ class MetadataEvent(
|
||||
.readerFor(UserMetadata::class.java)
|
||||
}
|
||||
|
||||
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
|
||||
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): MetadataEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class MuteListEvent(
|
||||
@@ -71,7 +71,7 @@ class MuteListEvent(
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): MuteListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class NNSEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun ip4() = tags.firstOrNull { it.size > 1 && it[0] == "ip4" }?.get(1)
|
||||
fun ip6() = tags.firstOrNull { it.size > 1 && it[0] == "ip6" }?.get(1)
|
||||
fun version() = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 30053
|
||||
|
||||
fun create(
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): NNSEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return NNSEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class PeopleListEvent(
|
||||
@@ -15,42 +18,133 @@ class PeopleListEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
var publicAndPrivateUserCache: ImmutableSet<HexKey>? = null
|
||||
|
||||
fun publicAndPrivateUsers(privateKey: ByteArray?): ImmutableSet<HexKey> {
|
||||
publicAndPrivateUserCache?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val privateUserList = privateKey?.let {
|
||||
privateTagsOrEmpty(privKey = it).filter { it.size > 1 && it[0] == "p" }.map { it[1] }.toSet()
|
||||
} ?: emptySet()
|
||||
val publicUserList = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }.toSet()
|
||||
|
||||
publicAndPrivateUserCache = (privateUserList + publicUserList).toImmutableSet()
|
||||
|
||||
return publicAndPrivateUserCache ?: persistentSetOf()
|
||||
}
|
||||
|
||||
fun isTaggedUser(idHex: String, isPrivate: Boolean, privateKey: ByteArray): Boolean {
|
||||
return if (isPrivate) {
|
||||
privateTagsOrEmpty(privKey = privateKey).any { it.size > 1 && it[0] == "p" && it[1] == idHex }
|
||||
} else {
|
||||
isTaggedUser(idHex)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 30000
|
||||
const val blockList = "mute"
|
||||
|
||||
fun create(
|
||||
name: String = "",
|
||||
|
||||
events: List<String>? = null,
|
||||
users: List<String>? = null,
|
||||
addresses: List<ATag>? = null,
|
||||
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PeopleListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
events?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
users?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
fun createListWithUser(name: String, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
|
||||
return if (isPrivate) {
|
||||
create(
|
||||
content = encryptTags(listOf(listOf("p", pubKeyHex)), privateKey),
|
||||
tags = listOf(listOf("d", name)),
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
} else {
|
||||
create(
|
||||
content = "",
|
||||
tags = listOf(listOf("d", name), listOf("p", pubKeyHex)),
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
fun addUsers(earlierVersion: PeopleListEvent, listPubKeyHex: List<String>, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
|
||||
return if (isPrivate) {
|
||||
create(
|
||||
content = encryptTags(
|
||||
privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).plus(
|
||||
listPubKeyHex.map {
|
||||
listOf("p", it)
|
||||
}
|
||||
),
|
||||
privateKey = privateKey
|
||||
),
|
||||
tags = earlierVersion.tags,
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
} else {
|
||||
create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.plus(
|
||||
listPubKeyHex.map {
|
||||
listOf("p", it)
|
||||
}
|
||||
),
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
|
||||
if (earlierVersion.isTaggedUser(pubKeyHex, isPrivate, privateKey)) return earlierVersion
|
||||
|
||||
return if (isPrivate) {
|
||||
create(
|
||||
content = encryptTags(
|
||||
privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).plus(element = listOf("p", pubKeyHex)),
|
||||
privateKey = privateKey
|
||||
),
|
||||
tags = earlierVersion.tags,
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
} else {
|
||||
create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.plus(element = listOf("p", pubKeyHex)),
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
|
||||
if (!earlierVersion.isTaggedUser(pubKeyHex, isPrivate, privateKey)) return earlierVersion
|
||||
|
||||
return if (isPrivate) {
|
||||
create(
|
||||
content = encryptTags(
|
||||
privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).filter { it.size > 1 && it[1] != pubKeyHex },
|
||||
privateKey = privateKey
|
||||
),
|
||||
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex },
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
} else {
|
||||
create(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex },
|
||||
privateKey = privateKey,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun create(content: String, tags: List<List<String>>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
return PeopleListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class PinListEvent(
|
||||
@@ -27,7 +27,7 @@ class PinListEvent(
|
||||
fun create(
|
||||
pins: List<String>,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): PinListEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
pins.forEach {
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
const val POLL_OPTION = "poll_option"
|
||||
const val VALUE_MAXIMUM = "value_maximum"
|
||||
@@ -45,14 +45,15 @@ class PollNoteEvent(
|
||||
mentions: List<String>?,
|
||||
addresses: List<ATag>?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
pollOptions: Map<Int, String>,
|
||||
valueMaximum: Int?,
|
||||
valueMinimum: Int?,
|
||||
consensusThreshold: Int?,
|
||||
closedAt: Int?,
|
||||
zapReceiver: String?,
|
||||
markAsSensitive: Boolean
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?
|
||||
): PollNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
@@ -79,6 +80,9 @@ class PollNoteEvent(
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -3,11 +3,12 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.HexValidator
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class PrivateDmEvent(
|
||||
@@ -23,11 +24,18 @@ class PrivateDmEvent(
|
||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||
* for initial messages.
|
||||
*/
|
||||
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
|
||||
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
|
||||
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull()
|
||||
|
||||
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
|
||||
fun verifiedRecipientPubKey(): HexKey? {
|
||||
val recipient = recipientPubKey()
|
||||
return if (HexValidator.isHex(recipient)) {
|
||||
recipient
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun talkingWith(oneSideHex: String): HexKey {
|
||||
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
|
||||
@@ -75,10 +83,11 @@ class PrivateDmEvent(
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
publishedRecipientPubKey: ByteArray? = null,
|
||||
advertiseNip18: Boolean = true,
|
||||
markAsSensitive: Boolean
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?
|
||||
): PrivateDmEvent {
|
||||
val content = Utils.encrypt(
|
||||
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
|
||||
@@ -102,6 +111,9 @@ class PrivateDmEvent(
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ReactionEvent(
|
||||
@@ -22,15 +22,15 @@ class ReactionEvent(
|
||||
companion object {
|
||||
const val kind = 7
|
||||
|
||||
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
|
||||
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
|
||||
return create("+", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
|
||||
@@ -42,5 +42,24 @@ class ReactionEvent(
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(emojiUrl: EmojiUrl, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
|
||||
val content = ":${emojiUrl.code}:"
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
var tags = listOf(
|
||||
listOf("e", originalNote.id()),
|
||||
listOf("p", originalNote.pubKey()),
|
||||
listOf("emoji", emojiUrl.code, emojiUrl.url)
|
||||
)
|
||||
|
||||
if (originalNote is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.net.URI
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class RecommendRelayEvent(
|
||||
@@ -27,7 +27,7 @@ class RecommendRelayEvent(
|
||||
companion object {
|
||||
const val kind = 2
|
||||
|
||||
fun create(relay: URI, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RecommendRelayEvent {
|
||||
fun create(relay: URI, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RecommendRelayEvent {
|
||||
val content = relay.toString()
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf<List<String>>()
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class RelayAuthEvent(
|
||||
@@ -21,7 +21,7 @@ class RelayAuthEvent(
|
||||
companion object {
|
||||
const val kind = 22242
|
||||
|
||||
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent {
|
||||
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RelayAuthEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class RelaySetEvent(
|
||||
@@ -28,7 +28,7 @@ class RelaySetEvent(
|
||||
fun create(
|
||||
relays: List<String>,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): RelaySetEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
relays.forEach {
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
|
||||
@@ -58,7 +58,7 @@ class ReportEvent(
|
||||
type: ReportType,
|
||||
privateKey: ByteArray,
|
||||
content: String = "",
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): ReportEvent {
|
||||
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
|
||||
@@ -75,7 +75,7 @@ class ReportEvent(
|
||||
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase())
|
||||
|
||||
@@ -2,10 +2,10 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class RepostEvent(
|
||||
@@ -17,8 +17,8 @@ class RepostEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun boostedPost() = taggedEvents()
|
||||
fun originalAuthor() = taggedUsers()
|
||||
|
||||
fun containedPost() = try {
|
||||
fromJson(content, Client.lenient)
|
||||
@@ -29,7 +29,7 @@ class RepostEvent(
|
||||
companion object {
|
||||
const val kind = 6
|
||||
|
||||
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
|
||||
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RepostEvent {
|
||||
val content = boostedPost.toJson()
|
||||
|
||||
val replyToPost = listOf("e", boostedPost.id())
|
||||
|
||||
@@ -4,10 +4,10 @@ import androidx.compose.runtime.Immutable
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class TextNoteEvent(
|
||||
@@ -34,12 +34,13 @@ class TextNoteEvent(
|
||||
extraTags: List<String>?,
|
||||
zapReceiver: String?,
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?,
|
||||
replyingTo: String?,
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): TextNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
@@ -89,6 +90,9 @@ class TextNoteEvent(
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -67,7 +67,7 @@ object Nip19 {
|
||||
}
|
||||
parsed?.copy(additionalChars = additionalChars ?: "")
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
|
||||
Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +73,18 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
|
||||
fun send(
|
||||
signedEvent: EventInterface,
|
||||
relay: String? = null,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
onDone: (() -> Unit)? = null
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (relay == null) {
|
||||
if (relayList != null) {
|
||||
RelayPool.sendToSelectedRelays(relayList, signedEvent)
|
||||
} else if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user