mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-04 22:04:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e8106aa9 | ||
|
|
e86ae4ac41 | ||
|
|
12570d3e26 | ||
|
|
8fe2c895de | ||
|
|
f251d56b1a | ||
|
|
e5518e23a8 | ||
|
|
d6f3e9540f | ||
|
|
79e7bb2931 | ||
|
|
3e3d683234 | ||
|
|
429f54848a | ||
|
|
4085285f90 | ||
|
|
b3d219b2de | ||
|
|
797eda2ae2 | ||
|
|
2478fdbcce | ||
|
|
d7cf198134 | ||
|
|
681e3eb44e | ||
|
|
5cc851ecc8 | ||
|
|
2a0426b845 | ||
|
|
567cf83b92 | ||
|
|
c7debb272d | ||
|
|
cf7464b5b6 | ||
|
|
9cb3406d7d | ||
|
|
3613fce68d | ||
|
|
1b62574eca | ||
|
|
fbd5a3296c | ||
|
|
c5e61400b7 | ||
|
|
42dd900491 | ||
|
|
295f1f53cd | ||
|
|
0b2b2d58ab | ||
|
|
c1637de63a | ||
|
|
566b44f4ef | ||
|
|
6275051857 | ||
|
|
58e0aa5f8f | ||
|
|
d6b5af6d77 | ||
|
|
9432135847 | ||
|
|
e7be1cc8e7 | ||
|
|
c0bdb40c7d | ||
|
|
bb59f68253 | ||
|
|
8961675a3e | ||
|
|
3dd5d5c4b2 | ||
|
|
bd8e9da966 | ||
|
|
6067a7d890 | ||
|
|
42a27a6d8d | ||
|
|
308a79a305 | ||
|
|
5e5c393472 | ||
|
|
ea905ef6ed | ||
|
|
5f98a54452 | ||
|
|
8627fd4b4c | ||
|
|
2a65b7241a | ||
|
|
7863961aba | ||
|
|
a84be7fc61 | ||
|
|
657f99a65a | ||
|
|
91591abd14 | ||
|
|
fd58da2a93 | ||
|
|
b35a59372c | ||
|
|
c1113f9df9 | ||
|
|
bd3d7e1aa3 | ||
|
|
47f3fe5cc6 | ||
|
|
b6e16ad470 | ||
|
|
b469915905 | ||
|
|
d3b557b778 | ||
|
|
1154f35c77 | ||
|
|
5ae552117d | ||
|
|
fe80b509e3 | ||
|
|
f3bc190b0b | ||
|
|
3674f6b354 | ||
|
|
3e32a91e34 | ||
|
|
da9027b430 | ||
|
|
10d6011009 | ||
|
|
62d22bf4ca | ||
|
|
d05d0f0829 | ||
|
|
0aaf1f9f34 | ||
|
|
756d9b17f7 | ||
|
|
f52bf26520 | ||
|
|
f4d5785710 | ||
|
|
75901d83a8 | ||
|
|
f34dea868d | ||
|
|
e07400409f | ||
|
|
8e858996fe | ||
|
|
f1711719f4 | ||
|
|
8d7897601b | ||
|
|
83f46f1a66 | ||
|
|
88ceb436ac | ||
|
|
3cd37b8658 | ||
|
|
04e099a212 | ||
|
|
c0bcb638c0 | ||
|
|
0369b45385 | ||
|
|
bab314ebc9 | ||
|
|
5a127706d7 | ||
|
|
4f9b1ab72c | ||
|
|
65d4d59e77 | ||
|
|
b95c013714 | ||
|
|
10ce5a984e | ||
|
|
3521497cf8 | ||
|
|
0da542fede | ||
|
|
d883cc32f5 | ||
|
|
64debb79fc | ||
|
|
4ee6759957 | ||
|
|
723db61e8d | ||
|
|
cb39281268 | ||
|
|
baa1b3f69f | ||
|
|
c7bf4240dd | ||
|
|
fb7edd1797 | ||
|
|
4448e38100 | ||
|
|
716ebd01df | ||
|
|
58521b27de | ||
|
|
f4db64fe7f | ||
|
|
d704352f1f | ||
|
|
511cc86025 | ||
|
|
58e8e0591b | ||
|
|
57fc149051 | ||
|
|
55cc780c08 | ||
|
|
01a7ef3785 | ||
|
|
b77e1cf960 | ||
|
|
c6eb5a7282 | ||
|
|
bf48428fe3 | ||
|
|
f4b366c4bd | ||
|
|
51ce424352 | ||
|
|
01b8b2a5fa | ||
|
|
459e503c8a | ||
|
|
708f6b2e75 | ||
|
|
b7b7f4df39 | ||
|
|
855b2ff5b1 | ||
|
|
67e6349877 | ||
|
|
5eb3b2062c | ||
|
|
c649d0820c | ||
|
|
07fd8fc5ac | ||
|
|
e6afa7fb9a | ||
|
|
e0596dc3b9 | ||
|
|
381db713ab | ||
|
|
b6f5cc9ae5 | ||
|
|
df43e730ca | ||
|
|
95423bdd94 | ||
|
|
a18479e721 | ||
|
|
f68f32f6e5 | ||
|
|
ddc1e9fd69 | ||
|
|
3f99b602a7 | ||
|
|
b09577f816 | ||
|
|
3780ed001a | ||
|
|
f8e49df126 | ||
|
|
55e31d445e | ||
|
|
5300d7bbe4 | ||
|
|
77fbb6f4f6 | ||
|
|
b570d80ac4 | ||
|
|
a7e17c810d | ||
|
|
256de32b7c | ||
|
|
554edb8bb9 | ||
|
|
bb3d8e0041 | ||
|
|
254ed99625 | ||
|
|
4c916c7957 | ||
|
|
7e3cfcdcaf | ||
|
|
528efacf38 | ||
|
|
5dac1899fe | ||
|
|
6bff898fe4 | ||
|
|
f065777df2 | ||
|
|
36d865510c | ||
|
|
6558627eef | ||
|
|
f518969875 | ||
|
|
1a7ecca89a | ||
|
|
26da2f514e | ||
|
|
4c0651a6db | ||
|
|
59fce55822 | ||
|
|
c88425e737 | ||
|
|
d0dc44f3bd | ||
|
|
25cb084a95 | ||
|
|
0b2d9d6d18 | ||
|
|
145428c8ad | ||
|
|
0cebb53791 | ||
|
|
8e6a4921a6 | ||
|
|
b806e71396 | ||
|
|
08f17bca27 | ||
|
|
14ec001601 | ||
|
|
dc763088f3 | ||
|
|
c087c5017c | ||
|
|
2eff0626ec | ||
|
|
afc43ccd28 | ||
|
|
81f9edf00c | ||
|
|
bdf2896df9 | ||
|
|
8a2a1887f2 | ||
|
|
26c8808a6b | ||
|
|
3db1017088 | ||
|
|
9178135127 | ||
|
|
08b0d8032b | ||
|
|
a8e9189fce | ||
|
|
efbaa0f434 | ||
|
|
0b4d11d5e6 | ||
|
|
661cbab4ee | ||
|
|
ba21ae47ae | ||
|
|
5e066e2485 | ||
|
|
3ebcfa111c | ||
|
|
c4046c0a88 | ||
|
|
3cdc77a277 | ||
|
|
951a4be615 | ||
|
|
3e7d2a7b68 | ||
|
|
19854e2295 | ||
|
|
21b10977d6 | ||
|
|
f2ffa4c915 | ||
|
|
bc50f08ca2 | ||
|
|
236177c6ce | ||
|
|
b1bdd20255 | ||
|
|
de26417391 | ||
|
|
f7a02c75da | ||
|
|
2b16472160 | ||
|
|
2e1b141e38 | ||
|
|
7acb735151 | ||
|
|
4fc4126740 | ||
|
|
04dec3073a | ||
|
|
40f1ef00a4 | ||
|
|
d3504129b4 | ||
|
|
3ba6f84a43 | ||
|
|
a399aa0ddd | ||
|
|
b1a3c2542f | ||
|
|
866e31e75b | ||
|
|
2a316de002 | ||
|
|
37da339683 | ||
|
|
d35dd18072 | ||
|
|
c12ec88e96 | ||
|
|
f455898ac4 | ||
|
|
f11a33f981 | ||
|
|
34417f5c33 | ||
|
|
60af6dcd0c | ||
|
|
648a1c7ec1 | ||
|
|
6da089d864 | ||
|
|
ccd1bdcad0 | ||
|
|
e4b94e2fea | ||
|
|
bf3847eeaa | ||
|
|
e4129276a1 | ||
|
|
dbd2f308c2 | ||
|
|
61920ed31a | ||
|
|
8b9b6061b9 | ||
|
|
83f785c5c4 | ||
|
|
639e3a5087 | ||
|
|
bf83f686d7 | ||
|
|
bce30ce5de | ||
|
|
da949fa9fd | ||
|
|
da877d5f79 | ||
|
|
520923e33a | ||
|
|
c5ceec8015 | ||
|
|
c0c2c668e3 | ||
|
|
6d21653b36 | ||
|
|
39e8289375 | ||
|
|
87975f0ca2 | ||
|
|
fd70d05768 | ||
|
|
85c7d0bb4a | ||
|
|
63262a1e35 | ||
|
|
987925d837 | ||
|
|
a2f14c3671 | ||
|
|
110c074993 | ||
|
|
4d56de3335 | ||
|
|
5ab3ce84d3 | ||
|
|
e8b09a9ba3 | ||
|
|
18f25aa4d8 | ||
|
|
a34cf117a0 | ||
|
|
4b0d1d32a6 | ||
|
|
ccf7d78cea | ||
|
|
bc2ef6500a | ||
|
|
92b1744c35 | ||
|
|
2595d6fa2a | ||
|
|
8886ad83e0 | ||
|
|
e52d7a7881 | ||
|
|
d31c11f70d | ||
|
|
a153f577c9 | ||
|
|
9213394731 | ||
|
|
a24e6391dc | ||
|
|
dbcd5ed7fe | ||
|
|
1f2e120851 | ||
|
|
88432d4ab6 | ||
|
|
7e2b8397e7 | ||
|
|
3b990c5f2d | ||
|
|
8b70a11754 | ||
|
|
f36906f6c0 | ||
|
|
19cc0d1ce8 | ||
|
|
1f953cb728 | ||
|
|
70c20fbb6d | ||
|
|
8db743c1e3 | ||
|
|
51061250e2 | ||
|
|
ddd35c5119 | ||
|
|
dcc84f4572 | ||
|
|
0236837856 | ||
|
|
892214ec27 | ||
|
|
dd404efc7d | ||
|
|
2bd3792b8f | ||
|
|
ea18b5d5cc | ||
|
|
2eae1ad434 | ||
|
|
03dd09e957 | ||
|
|
f34668b90d | ||
|
|
504f9cf978 | ||
|
|
182ac5de07 | ||
|
|
6063bd789d | ||
|
|
c610d130f8 | ||
|
|
23a5e9e66b | ||
|
|
d57fca3549 | ||
|
|
8bc5aab880 | ||
|
|
71b86ea69c | ||
|
|
71dad0c69e | ||
|
|
b8cb6295f4 | ||
|
|
666972fd62 | ||
|
|
0914a7b68c | ||
|
|
de2ec503da | ||
|
|
53a14d3a77 | ||
|
|
dfe95c28ea | ||
|
|
bf9f4202d4 | ||
|
|
54cdb5ae68 | ||
|
|
6ff474376f | ||
|
|
138b22348c | ||
|
|
2ac5174b78 | ||
|
|
89f714b1ad | ||
|
|
d2c39b022b | ||
|
|
ccd6beee8e | ||
|
|
b7c72a282f | ||
|
|
0d1bf72708 | ||
|
|
34bdc2b103 | ||
|
|
cc8866d240 | ||
|
|
b60d7ff857 | ||
|
|
4f53a74004 | ||
|
|
f0e09197ff | ||
|
|
4afcf48392 | ||
|
|
943d4525be | ||
|
|
de302eb966 | ||
|
|
85adc722ae | ||
|
|
c8c48cca32 | ||
|
|
3f68b42acd | ||
|
|
da5c61038d | ||
|
|
e8949f08ae | ||
|
|
068b5faf7d | ||
|
|
7dd01ae5e4 | ||
|
|
3493802a1e | ||
|
|
9ec9feda28 | ||
|
|
0277fa7364 | ||
|
|
af96f142cd | ||
|
|
f80ba653b2 | ||
|
|
5947d03cb3 | ||
|
|
694cfdf322 | ||
|
|
10efe5d17d | ||
|
|
d964f93e67 | ||
|
|
4996c5a9d8 | ||
|
|
c0fe13d79c | ||
|
|
c56d3af0fc | ||
|
|
a050236d6a | ||
|
|
90bdeffc52 | ||
|
|
e3ed676624 | ||
|
|
5e5ea32be1 | ||
|
|
1306270887 | ||
|
|
fbff78208e | ||
|
|
8468212440 | ||
|
|
7f5a173259 | ||
|
|
1b8d83f41a | ||
|
|
e381e2f05f | ||
|
|
6a05039bde | ||
|
|
76737e6b74 | ||
|
|
c3aa37534e | ||
|
|
8e897762dd | ||
|
|
790cbb35db | ||
|
|
743c23f3c8 | ||
|
|
385a1c3849 | ||
|
|
a3a3f8ea99 | ||
|
|
25b470cde6 | ||
|
|
05d71fd27e | ||
|
|
f5e61b9409 | ||
|
|
620bd4d5a8 | ||
|
|
3f671350ce | ||
|
|
b89a8e5f8f | ||
|
|
a01a282e2e | ||
|
|
2ed0ca5fdb | ||
|
|
49366ac47d | ||
|
|
2ac4ed8ac4 | ||
|
|
bb76bbd313 | ||
|
|
2c5ba69657 | ||
|
|
a618e84d32 | ||
|
|
108ad4dadc | ||
|
|
80bede648f | ||
|
|
e5ee367761 | ||
|
|
4e53a9fda0 | ||
|
|
caa170f78e | ||
|
|
a857b587a1 | ||
|
|
420bacea3d | ||
|
|
b2da651fd1 | ||
|
|
04d94b4dbf | ||
|
|
64f2575228 | ||
|
|
dc07aea525 | ||
|
|
21ac1542ee | ||
|
|
9cd90db6b3 | ||
|
|
8a65f3fbe7 | ||
|
|
e6153e7ef5 | ||
|
|
c62c4c7545 | ||
|
|
c4d3c105f7 | ||
|
|
3d3578666d | ||
|
|
01c060caa1 | ||
|
|
d6cd8e2804 | ||
|
|
fa559c8428 | ||
|
|
a38087a9b9 | ||
|
|
29dc677148 | ||
|
|
50bc55e84b | ||
|
|
ae8d39df92 | ||
|
|
09919e99b9 | ||
|
|
5069c30a4a | ||
|
|
4097263c12 | ||
|
|
88e91e37fa | ||
|
|
a003e111eb | ||
|
|
4cde2fe8e6 | ||
|
|
ffe5e79474 | ||
|
|
19a9e7a949 | ||
|
|
757e566774 | ||
|
|
7fff535b74 | ||
|
|
65ef34511a | ||
|
|
644c6b4d76 | ||
|
|
dec17e162e | ||
|
|
df49c63936 | ||
|
|
f0d4ad38a5 | ||
|
|
7cb146bb50 | ||
|
|
5195ddf63e | ||
|
|
3fde6b4b1f | ||
|
|
5102a15532 | ||
|
|
00981ef15c | ||
|
|
f2badce3b8 | ||
|
|
e9f0fb82e9 | ||
|
|
abf217b71d | ||
|
|
9faf6a2fbc | ||
|
|
f61b894ddf | ||
|
|
b0dda37023 | ||
|
|
d600d1c3e5 | ||
|
|
79947524cc | ||
|
|
2e49bbc606 | ||
|
|
3a7228712c |
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Video and Screenshots**
|
||||
If applicable, add a video and/or screenshots to help explain your problem.
|
||||
|
||||
**Device (please complete the following information):**
|
||||
- Phone Brand/Model [e.g. Pixel 7 Pro]:
|
||||
- Android Version [e.g. 33]:
|
||||
- App Version [e.g. v0.20.3]:
|
||||
|
||||
**Bounty (in Bitcoin sats) offered for a solution**
|
||||
Incentivize developers to work on your issue. Describe clear milestones to claim payment.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Bounty (in Bitcoin sats) offered for the implementation**
|
||||
Incentivize developers to work on your feature. Describe clear milestones to claim payment.
|
||||
|
||||
**Additional context**
|
||||
Add any other context, video, or screenshots about the feature request here.
|
||||
Generated
+1
@@ -0,0 +1 @@
|
||||
Amethyst
|
||||
+3
@@ -5,6 +5,9 @@
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.8.10" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
@@ -6,27 +6,49 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Account Management
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
- [x] Reactions (like, boost, reply)
|
||||
- [x] Image Preview (gifs, svgs)
|
||||
- [x] Url Preview
|
||||
- [x] Reactions (likes NIP-25, boost, reply)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] View Threads
|
||||
- [x] Private Messages (NIP-04)
|
||||
- [x] User Profiles (follow/unfollow)
|
||||
- [x] User Profiles (edit/follow/unfollow - NIP-02)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [ ] Notification Bubbles
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Dropdown to Link Users/Posts when writing
|
||||
- [ ] Identity Verification (NIP-05)
|
||||
- [ ] Event Delegation (NIP-09)
|
||||
- [ ] Filter messages from Unknown People
|
||||
- [ ] Profile Edit
|
||||
- [ ] Relay Edit
|
||||
- [ ] Account Creation / Backup Guidance
|
||||
- [ ] Message Sent feedback
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
- [x] Notification Dots
|
||||
- [x] Reporting and Hide User capability (NIP-56)
|
||||
- [x] Automatic Translations
|
||||
- [x] Relay Sets (home, dms, public chats, global)
|
||||
- [x] User/Note Tagging (NIP-08, NIP-10)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Sensitive Content (NIP-36)
|
||||
- [ ] Relay Pages (NIP-11)
|
||||
- [ ] Relay Authentication (NIP-42)
|
||||
- [ ] Generic Tags (NIP-12)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Events with a Subject (NIP-14)
|
||||
- [ ] Workspaces
|
||||
- [ ] Expiration Support (NIP-40)
|
||||
- [ ] Internationalization
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Badges (NIP-58)
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [ ] Signing Requests (NIP-46)
|
||||
- [ ] Delegated Event Signing (NIP-26)
|
||||
- [ ] Account Creation / Backup Guidance (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
|
||||
# Development Overview
|
||||
|
||||
@@ -123,4 +145,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
|
||||
+46
-20
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 38
|
||||
versionName "0.13.2"
|
||||
versionCode 89
|
||||
versionName "0.24.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -22,7 +22,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
//minifyEnabled false
|
||||
//minifyEnabled true
|
||||
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
resValue "string", "app_name", "@string/app_name_release"
|
||||
}
|
||||
@@ -43,7 +43,7 @@ android {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.4.0'
|
||||
kotlinCompilerExtensionVersion "1.4.3"
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
@@ -54,27 +54,33 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.activity:activity-compose:1.6.1'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
// Needs this to open gallery / image upload
|
||||
implementation "androidx.fragment:fragment-ktx:$fragment_version"
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:$nav_version")
|
||||
|
||||
// Observe Live data as State
|
||||
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha04'
|
||||
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-beta02'
|
||||
|
||||
implementation 'androidx.compose.material:material:1.4.0-alpha05'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.4.0-alpha05"
|
||||
implementation 'androidx.compose.material:material:1.4.0-beta02'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.4.0-beta02"
|
||||
|
||||
// Lifecycle
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha05'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha05'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha05'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata:2.6.0-alpha05'
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Swipe Refresh
|
||||
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.0-alpha'
|
||||
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.1-alpha'
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
|
||||
@@ -86,19 +92,24 @@ dependencies {
|
||||
exclude module: 'guava-testlib'
|
||||
}
|
||||
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
// Websockets API
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// Json Serialization
|
||||
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// Robohash for Avatars
|
||||
implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar'
|
||||
|
||||
// link preview
|
||||
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.2'
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.4'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:2.2.2"
|
||||
@@ -116,21 +127,36 @@ dependencies {
|
||||
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
|
||||
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
|
||||
|
||||
// Parses URLs from Text:
|
||||
implementation "io.github.url-detector:url-detector:0.1.23"
|
||||
|
||||
// For QR generation
|
||||
implementation 'com.google.zxing:core:3.5.1'
|
||||
implementation "androidx.camera:camera-camera2:1.2.1"
|
||||
implementation 'androidx.camera:camera-lifecycle:1.2.1'
|
||||
implementation 'androidx.camera:camera-view:1.2.1'
|
||||
|
||||
// Markdown
|
||||
implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
|
||||
implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
|
||||
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
|
||||
|
||||
// For QR Scanning
|
||||
implementation 'com.google.mlkit:vision-common:17.3.0'
|
||||
|
||||
// Local Barcode Scanning model
|
||||
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
|
||||
// The idea is to make it work for degoogled phones
|
||||
implementation 'com.google.mlkit:barcode-scanning:17.0.3'
|
||||
|
||||
// Use this dependency to use the translate text
|
||||
// Local model for language identification
|
||||
implementation 'com.google.mlkit:language-id:17.0.4'
|
||||
|
||||
// Google services model the translate text
|
||||
implementation 'com.google.mlkit:translate:17.0.1'
|
||||
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
|
||||
|
||||
|
||||
// Automatic memory leak detection
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
|
||||
Vendored
+8
-2
@@ -25,7 +25,13 @@
|
||||
-keep class fr.acinq.secp256k1.jni.** { *; }
|
||||
# For the NostrPostr library
|
||||
-keep class nostr.postr.** { *; }
|
||||
-keep class nostr.postr.events.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
|
||||
# Json parsing
|
||||
-keep class com.google.gson.reflect.** { *; }
|
||||
-keep class * extends com.google.gson.reflect.TypeToken
|
||||
-keep public class * implements java.lang.reflect.Type
|
||||
|
||||
-keep class com.vitorpamplona.amethyst.lnurl.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.model.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.ui.** { *; }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.gson.JsonElement
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EventSigCheck {
|
||||
|
||||
val payload1 = "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nI’ll give you one final explanation to rule them all. First, let’s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays it’s 500, others 1000, some as high as 5000. Let’s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That won’t change if you have 20,000 followers or 100,000. You may get back a “different” 5000 each time, but you’ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesn’t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]"
|
||||
|
||||
@Test
|
||||
fun testUnicode2028and2029ShouldNotBeEscaped() {
|
||||
val msg = Event.gson.fromJson(payload1, JsonElement::class.java).asJsonArray
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
|
||||
// Should pass
|
||||
event.checkSignature()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.android.gms.tasks.Tasks
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TranslationsTest {
|
||||
|
||||
fun translatePT(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))
|
||||
}
|
||||
|
||||
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
|
||||
val translated = translatePT(input, translateTo)!!
|
||||
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslation() {
|
||||
assertTranslate("Olá mundo", "Hello World", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationName() {
|
||||
assertTranslate("Olá Vitor, como você está?", "Hello Vitor, how are you doing?", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationTag() {
|
||||
assertTranslate("Você já viu isso, #[0]", "Have you seen this, #[0]", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationUrl() {
|
||||
assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt")
|
||||
assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt")
|
||||
assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt")
|
||||
assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt")
|
||||
assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt")
|
||||
assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt")
|
||||
assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt")
|
||||
assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt")
|
||||
assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChineseWithUrlDetector() {
|
||||
assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端", "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationEmail() {
|
||||
assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationLnInvoice() {
|
||||
assertTranslateContains(
|
||||
"lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn",
|
||||
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", "pt"
|
||||
)
|
||||
|
||||
assertTranslateContains(
|
||||
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", "pt"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
|
||||
@@ -29,7 +30,8 @@ class EUrlUserTagTransformationTest {
|
||||
@Test
|
||||
fun transformationText() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
user.info.displayName = "Vitor Pamplona"
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
var transformedText = buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
|
||||
@@ -63,7 +65,8 @@ class EUrlUserTagTransformationTest {
|
||||
@Test
|
||||
fun transformationTextTwoKeys() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
user.info.displayName = "Vitor Pamplona"
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
var transformedText = buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Used for SDK < 29 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -19,7 +24,6 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="33">
|
||||
<activity
|
||||
android:label="@string/app_name"
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -41,10 +45,6 @@
|
||||
android:name="android.app.lib_name"
|
||||
android:value="" />
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="barcode" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -4,75 +4,119 @@ import android.content.Context
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.DefaultChannels
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import java.util.Locale
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.Event
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
class LocalPreferences(context: Context) {
|
||||
val encryptedPreferences = EncryptedStorage().preferences(context)
|
||||
val gson = GsonBuilder().create()
|
||||
|
||||
fun clearEncryptedStorage() {
|
||||
encryptedPreferences.edit().apply {
|
||||
remove("nostr_privkey")
|
||||
remove("nostr_pubkey")
|
||||
remove("following_channels")
|
||||
remove("hidden_users")
|
||||
remove("relays")
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
encryptedPreferences.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
|
||||
account.followingChannels.let { putStringSet("following_channels", it) }
|
||||
account.hiddenUsers.let { putStringSet("hidden_users", it) }
|
||||
account.localRelays.let { putString("relays", gson.toJson(it)) }
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences.apply {
|
||||
val privKey = getString("nostr_privkey", null)
|
||||
val pubKey = getString("nostr_pubkey", null)
|
||||
val followingChannels = getStringSet("following_channels", null)?.toMutableSet() ?: mutableSetOf()
|
||||
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
val localRelays = gson.fromJson(
|
||||
getString("relays", "[]"),
|
||||
object : TypeToken<Set<NewRelayListViewModel.Relay>>() {}.type
|
||||
) ?: setOf<NewRelayListViewModel.Relay>()
|
||||
|
||||
if (pubKey != null) {
|
||||
return Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
private object PrefKeys {
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_${route}" }
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences.edit().apply {
|
||||
putLong("last_read_route_${route}", timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
private val encryptedPreferences = EncryptedStorage().preferences(context)
|
||||
private val gson = GsonBuilder().create()
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences.run {
|
||||
return getLong("last_read_route_${route}", 0)
|
||||
fun clearEncryptedStorage() {
|
||||
encryptedPreferences.edit().apply {
|
||||
encryptedPreferences.all.keys.forEach { remove(it) }
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
encryptedPreferences.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
|
||||
account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) }
|
||||
account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) }
|
||||
account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) }
|
||||
account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) }
|
||||
account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) }
|
||||
account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) }
|
||||
account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) }
|
||||
account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) }
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences.apply {
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
val localRelays = gson.fromJson(
|
||||
getString(PrefKeys.RELAYS, "[]"),
|
||||
object : TypeToken<Set<RelaySetupInfo>>() {}.type
|
||||
) ?: setOf<RelaySetupInfo>()
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
|
||||
val latestContactList = try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val languagePreferences = try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
|
||||
} ?: mapOf<String,String>()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf<String,String>()
|
||||
}
|
||||
|
||||
if (pubKey != null) {
|
||||
return Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays,
|
||||
dontTranslateFrom,
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
latestContactList
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences.edit().apply {
|
||||
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences.run {
|
||||
return getLong(PrefKeys.LAST_READ(route), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object NotificationCache {
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
@@ -20,7 +22,7 @@ object NotificationCache {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
LocalPreferences(context).saveLastRead(route, timestampInSecs)
|
||||
invalidateData()
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,24 +38,29 @@ object NotificationCache {
|
||||
|
||||
// Observers line up here.
|
||||
val live: NotificationLiveData = NotificationLiveData(this)
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
live.refresh()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.util.LruCache
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import java.util.UUID
|
||||
import name.neuhalfen.projects.android.robohash.buckets.VariableSizeHashing
|
||||
import name.neuhalfen.projects.android.robohash.handle.Handle
|
||||
import name.neuhalfen.projects.android.robohash.handle.HandleFactory
|
||||
import name.neuhalfen.projects.android.robohash.paths.Configuration
|
||||
import name.neuhalfen.projects.android.robohash.repository.ImageRepository
|
||||
|
||||
object RoboHashCache {
|
||||
|
||||
lateinit var robots: MyRoboHash
|
||||
|
||||
lateinit var defaultAvatar: ImageBitmap
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context, hash: String): ImageBitmap {
|
||||
if (!this::robots.isInitialized) {
|
||||
robots = MyRoboHash(context)
|
||||
|
||||
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
|
||||
}
|
||||
|
||||
return defaultAvatar
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Recreates RoboHash to use a custom configuration
|
||||
*/
|
||||
class MyRoboHash(context: Context) {
|
||||
private val configuration: Configuration = ModifiedSet1Configuration()
|
||||
private val repository: ImageRepository
|
||||
private val hashing = VariableSizeHashing(configuration.bucketSizes)
|
||||
|
||||
// Optional
|
||||
private var memoryCache: LruCache<String, Bitmap>? = null
|
||||
|
||||
init {
|
||||
repository = ImageRepository(context.assets)
|
||||
}
|
||||
|
||||
fun useCache(memoryCache: LruCache<String, Bitmap>?) {
|
||||
this.memoryCache = memoryCache
|
||||
}
|
||||
|
||||
fun calculateHandleFromUUID(uuid: UUID?): Handle {
|
||||
val data = hashing.createBuckets(uuid)
|
||||
return handleFactory.calculateHandle(data)
|
||||
}
|
||||
|
||||
fun imageForHandle(handle: Handle): Bitmap {
|
||||
if (null != memoryCache) {
|
||||
val cached = memoryCache!![handle.toString()]
|
||||
if (null != cached) return cached
|
||||
}
|
||||
val bucketValues = handle.bucketValues()
|
||||
val paths = configuration.convertToFacetParts(bucketValues)
|
||||
val sampleSize = 1
|
||||
val buffer = repository.createBuffer(configuration.width(), configuration.height())
|
||||
val target = buffer.copy(Bitmap.Config.ARGB_8888, true)
|
||||
val merged = Canvas(target)
|
||||
val paint = Paint(0)
|
||||
|
||||
// The first image is not added as copy form the buffer
|
||||
for (i in paths.indices) {
|
||||
merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint)
|
||||
}
|
||||
repository.returnBuffer(buffer)
|
||||
if (null != memoryCache) {
|
||||
memoryCache!!.put(handle.toString(), target)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val handleFactory = HandleFactory()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom configuration to avoid the use of String.format in the GeneratePath
|
||||
* This uses the default location and ends up encoding number in the local language
|
||||
*/
|
||||
class ModifiedSet1Configuration : Configuration {
|
||||
override fun convertToFacetParts(bucketValues: ByteArray): Array<String> {
|
||||
require(bucketValues.size == BUCKET_COUNT)
|
||||
val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()]
|
||||
val paths = mutableListOf<String>()
|
||||
|
||||
// e.g.
|
||||
// blue face #2
|
||||
// blue nose #7
|
||||
// blue
|
||||
val firstFacetBucket = BUCKET_COLOR + 1
|
||||
for (facet in 0 until FACET_COUNT) {
|
||||
val bucketValue = bucketValues[firstFacetBucket + facet].toInt()
|
||||
paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue))
|
||||
}
|
||||
return paths.toTypedArray()
|
||||
}
|
||||
|
||||
private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String {
|
||||
// TODO: Make more efficient
|
||||
return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color)
|
||||
.replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0'))
|
||||
}
|
||||
|
||||
override fun getBucketSizes(): ByteArray {
|
||||
return BUCKET_SIZES
|
||||
}
|
||||
|
||||
override fun width(): Int {
|
||||
return 300
|
||||
}
|
||||
|
||||
override fun height(): Int {
|
||||
return 300
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ROOT = "sets/set1"
|
||||
private const val BUCKET_COLOR = 0
|
||||
private const val COLOR_COUNT = 10
|
||||
private const val BODY_COUNT = 10
|
||||
private const val FACE_COUNT = 10
|
||||
private const val MOUTH_COUNT = 10
|
||||
private const val EYES_COUNT = 10
|
||||
private const val ACCESSORY_COUNT = 10
|
||||
private const val BUCKET_COUNT = 6
|
||||
private const val FACET_COUNT = 5
|
||||
private val BUCKET_SIZES = byteArrayOf(
|
||||
COLOR_COUNT.toByte(),
|
||||
BODY_COUNT.toByte(),
|
||||
FACE_COUNT.toByte(),
|
||||
MOUTH_COUNT.toByte(),
|
||||
EYES_COUNT.toByte(),
|
||||
ACCESSORY_COUNT.toByte()
|
||||
)
|
||||
private val INT_TO_COLOR = arrayOf(
|
||||
"blue",
|
||||
"brown",
|
||||
"green",
|
||||
"grey",
|
||||
"orange",
|
||||
"pink",
|
||||
"purple",
|
||||
"red",
|
||||
"white",
|
||||
"yellow"
|
||||
)
|
||||
private val FACET_PATH_TEMPLATES = arrayOf(
|
||||
"#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
|
||||
object ServiceManager {
|
||||
private var account: Account? = null
|
||||
@@ -29,30 +27,22 @@ object ServiceManager {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.convertLocalRelays())
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrNotificationDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
|
||||
NostrGlobalDataSource.account = myAccount
|
||||
NostrChannelDataSource.account = myAccount
|
||||
|
||||
NostrUserProfileDataSource.account = myAccount
|
||||
NostrUserProfileFollowsDataSource.account = myAccount
|
||||
NostrUserProfileFollowersDataSource.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrAccountDataSource.start()
|
||||
NostrGlobalDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrNotificationDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
NostrThreadDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
} else {
|
||||
// if not logged in yet, start a basic service wit default relays
|
||||
Client.connect(Constants.convertDefaultRelays())
|
||||
@@ -65,17 +55,25 @@ object ServiceManager {
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrUserProfileFollowersDataSource.stop()
|
||||
NostrUserProfileFollowsDataSource.stop()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrNotificationDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
//LocalCache.pruneNonFollows(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
|
||||
object VideoCache {
|
||||
|
||||
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
|
||||
|
||||
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
|
||||
|
||||
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
|
||||
lateinit var simpleCache: SimpleCache
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
fun get(context: Context): CacheDataSource.Factory {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,70 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
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.Contact
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.Contact
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import nostr.postr.toHex
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
|
||||
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
|
||||
)
|
||||
|
||||
fun getLanguagesSpokenByUser(): Set<String> {
|
||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||
val codedList = mutableSetOf<String>()
|
||||
for (i in 0 until languageList.size()) {
|
||||
languageList.get(i)?.let { codedList.add(it.language) }
|
||||
}
|
||||
return codedList
|
||||
}
|
||||
|
||||
class Account(
|
||||
val loggedIn: Persona,
|
||||
var followingChannels: Set<String> = DefaultChannels,
|
||||
var hiddenUsers: Set<String> = setOf(),
|
||||
var localRelays: Set<NewRelayListViewModel.Relay> = Constants.defaultRelays.toSet()
|
||||
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
|
||||
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
|
||||
var backupContactList: ContactListEvent? = null
|
||||
) {
|
||||
var transientHiddenUsers: Set<String> = setOf()
|
||||
|
||||
// Observers line up here.
|
||||
val live: AccountLiveData = AccountLiveData(this)
|
||||
val liveLanguages: AccountLiveData = AccountLiveData(this)
|
||||
val saveable: AccountLiveData = AccountLiveData(this)
|
||||
|
||||
fun userProfile(): User {
|
||||
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
|
||||
@@ -50,7 +75,7 @@ class Account(
|
||||
}
|
||||
|
||||
fun hiddenUsers(): List<User> {
|
||||
return hiddenUsers.map { LocalCache.getOrCreateUser(it) }
|
||||
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean {
|
||||
@@ -60,41 +85,56 @@ class Account(
|
||||
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
val event = if (lastestContactList != null) {
|
||||
ContactListEvent.create(
|
||||
lastestContactList.follows,
|
||||
val contactList = userProfile().latestContactList
|
||||
val follows = contactList?.follows() ?: emptyList()
|
||||
|
||||
if (contactList != null && follows.isNotEmpty()) {
|
||||
val event = ContactListEvent.create(
|
||||
follows,
|
||||
relays,
|
||||
loggedIn.privKey!!)
|
||||
} else {
|
||||
ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
} else {
|
||||
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
|
||||
|
||||
// Keep this local to avoid erasing a good contact list.
|
||||
// Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNewUserMetadata(toString: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
loggedIn.privKey?.let {
|
||||
val createdAt = Date().time / 1000
|
||||
val content = toString
|
||||
val pubKey = Utils.pubkeyCreate(it)
|
||||
val tags = listOf<List<String>>()
|
||||
val id = Event.generateId(pubKey, createdAt, MetadataEvent.kind, tags, content)
|
||||
val sig = Utils.sign(id, it)
|
||||
val event = MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
val event = MetadataEvent.create(toString, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun reactionTo(note: Note): List<Note> {
|
||||
return note.reactedBy(userProfile(), "+")
|
||||
}
|
||||
|
||||
fun hasBoosted(note: Note): Boolean {
|
||||
return boostsTo(note).isNotEmpty()
|
||||
}
|
||||
|
||||
fun boostsTo(note: Note): List<Note> {
|
||||
return note.boostedBy(userProfile())
|
||||
}
|
||||
|
||||
fun hasReacted(note: Note): Boolean {
|
||||
return note.hasReacted(userProfile(), "+")
|
||||
}
|
||||
|
||||
fun reactTo(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
if (note.hasReacted(userProfile(), "+")) {
|
||||
if (hasReacted(note)) {
|
||||
// has already liked this note
|
||||
return
|
||||
}
|
||||
@@ -106,6 +146,26 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createZapRequestFor(note: Note): LnZapRequestEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
note.event?.let {
|
||||
return LnZapRequestEvent.create(it, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun createZapRequestFor(user: User): LnZapRequestEvent? {
|
||||
return createZapRequestFor(user.pubkeyHex)
|
||||
}
|
||||
|
||||
fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
|
||||
}
|
||||
|
||||
fun report(note: Note, type: ReportEvent.ReportType) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -123,7 +183,7 @@ class Account(
|
||||
note.event?.let {
|
||||
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
LocalCache.consume(event, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,13 +197,29 @@ class Account(
|
||||
|
||||
val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
LocalCache.consume(event, null)
|
||||
}
|
||||
|
||||
fun delete(note: Note) {
|
||||
delete(listOf(note))
|
||||
}
|
||||
|
||||
fun delete(notes: List<Note>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex }
|
||||
|
||||
if (myNotes.isNotEmpty()) {
|
||||
val event = DeletionEvent.create(myNotes, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun boost(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
if (note.hasBoosted(userProfile())) {
|
||||
if (note.hasBoostedInTheLast5Minutes(userProfile())) {
|
||||
// has already bosted in the past 5mins
|
||||
return
|
||||
}
|
||||
@@ -164,10 +240,12 @@ class Account(
|
||||
fun follow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
val event = if (lastestContactList != null) {
|
||||
val contactList = userProfile().latestContactList
|
||||
val follows = contactList?.follows() ?: emptyList()
|
||||
|
||||
val event = if (contactList != null && follows.isNotEmpty()) {
|
||||
ContactListEvent.create(
|
||||
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)),
|
||||
follows.plus(Contact(user.pubkeyHex, null)),
|
||||
userProfile().relays,
|
||||
loggedIn.privKey!!)
|
||||
} else {
|
||||
@@ -186,12 +264,15 @@ class Account(
|
||||
fun unfollow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
if (lastestContactList != null) {
|
||||
val contactList = userProfile().latestContactList
|
||||
val follows = contactList?.follows() ?: emptyList()
|
||||
|
||||
if (contactList != null && follows.isNotEmpty()) {
|
||||
val event = ContactListEvent.create(
|
||||
lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
|
||||
follows.filter { it.pubKeyHex != user.pubkeyHex },
|
||||
userProfile().relays,
|
||||
loggedIn.privKey!!)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
@@ -202,18 +283,20 @@ class Account(
|
||||
|
||||
val repliesToHex = replyTo?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
val addresses = replyTo?.mapNotNull { it.address() }
|
||||
|
||||
val signedEvent = TextNoteEvent.create(
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
addresses = addresses,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
@@ -234,10 +317,15 @@ class Account(
|
||||
if (!isWriteable()) return
|
||||
val user = LocalCache.users[toUser] ?: return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val mentionsHex = emptyList<String>()
|
||||
|
||||
val signedEvent = PrivateDmEvent.create(
|
||||
recipientPubKey = user.pubkey,
|
||||
publishedRecipientPubKey = user.pubkey,
|
||||
recipientPubKey = user.pubkey(),
|
||||
publishedRecipientPubKey = user.pubkey(),
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
privateKey = loggedIn.privKey!!,
|
||||
advertiseNip18 = false
|
||||
)
|
||||
@@ -260,27 +348,40 @@ class Account(
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
|
||||
joinChannel(event.id.toHex())
|
||||
joinChannel(event.id)
|
||||
}
|
||||
|
||||
fun joinChannel(idHex: String) {
|
||||
followingChannels = followingChannels + idHex
|
||||
invalidateData()
|
||||
live.invalidateData()
|
||||
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun leaveChannel(idHex: String) {
|
||||
followingChannels = followingChannels - idHex
|
||||
invalidateData()
|
||||
live.invalidateData()
|
||||
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun hideUser(pubkeyHex: String) {
|
||||
hiddenUsers = hiddenUsers + pubkeyHex
|
||||
invalidateData()
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun showUser(pubkeyHex: String) {
|
||||
hiddenUsers = hiddenUsers - pubkeyHex
|
||||
invalidateData()
|
||||
transientHiddenUsers = transientHiddenUsers - pubkeyHex
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
zapAmountChoices = newAmounts
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
|
||||
@@ -299,7 +400,7 @@ class Account(
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
|
||||
joinChannel(event.id.toHex())
|
||||
joinChannel(event.id)
|
||||
}
|
||||
|
||||
fun decryptContent(note: Note): String? {
|
||||
@@ -307,36 +408,69 @@ class Account(
|
||||
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
|
||||
var pubkeyToUse = event.pubKey
|
||||
|
||||
val recepientPK = event.recipientPubKey
|
||||
val recepientPK = event.recipientPubKey()
|
||||
|
||||
if (note.author == userProfile() && recepientPK != null)
|
||||
pubkeyToUse = recepientPK
|
||||
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(loggedIn.privKey!!, pubkeyToUse)
|
||||
|
||||
val retVal = Utils.decrypt(event.content, sharedSecret)
|
||||
|
||||
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
|
||||
retVal.substring(16)
|
||||
} else {
|
||||
retVal
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
|
||||
} else {
|
||||
event?.content
|
||||
}
|
||||
}
|
||||
|
||||
fun addDontTranslateFrom(languageCode: String) {
|
||||
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
|
||||
liveLanguages.invalidateData()
|
||||
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateTranslateTo(languageCode: String) {
|
||||
translateTo = languageCode
|
||||
liveLanguages.invalidateData()
|
||||
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun prefer(source: String, target: String, preference: String) {
|
||||
languagePreferences = languagePreferences + Pair("$source,$target", preference)
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun preferenceBetween(source: String, target: String): String? {
|
||||
return languagePreferences.get("$source,$target")
|
||||
}
|
||||
|
||||
private fun updateContactListTo(newContactList: ContactListEvent?) {
|
||||
if (newContactList?.follows().isNullOrEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupContactList?.id != newContactList?.id) {
|
||||
backupContactList = newContactList
|
||||
saveable.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
// Takes a User's relay list and adds the types of feeds they are active for.
|
||||
fun activeRelays(): Array<Relay>? {
|
||||
return userProfile().relays?.map {
|
||||
var usersRelayList = userProfile().relays?.map {
|
||||
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
|
||||
}?.toTypedArray()
|
||||
} ?: return null
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
// TODO: Remove when search becomes more available.
|
||||
if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) {
|
||||
usersRelayList = usersRelayList + Relay(
|
||||
Constants.forcedRelayForSearch.url,
|
||||
Constants.forcedRelayForSearch.read,
|
||||
Constants.forcedRelayForSearch.write,
|
||||
Constants.forcedRelayForSearch.feedTypes
|
||||
)
|
||||
}
|
||||
|
||||
return usersRelayList.toTypedArray()
|
||||
}
|
||||
|
||||
fun convertLocalRelays(): Array<Relay> {
|
||||
@@ -345,52 +479,33 @@ class Account(
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
init {
|
||||
userProfile().subscribe(object: User.Listener() {
|
||||
override fun onRelayChange() {
|
||||
Client.disconnect()
|
||||
Client.connect(activeRelays() ?: convertLocalRelays())
|
||||
RelayPool.requestAndWatch()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: AccountLiveData = AccountLiveData(this)
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
live.refresh()
|
||||
handlerWaiting = false
|
||||
fun reconnectIfRelaysHaveChanged() {
|
||||
val newRelaySet = activeRelays() ?: convertLocalRelays()
|
||||
if (!Client.isSameRelaySetConfig(newRelaySet)) {
|
||||
Client.disconnect()
|
||||
Client.connect(newRelaySet)
|
||||
RelayPool.requestAndWatch()
|
||||
}
|
||||
}
|
||||
|
||||
fun isHidden(user: User) = user in hiddenUsers()
|
||||
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
|
||||
|
||||
fun isAcceptable(user: User): Boolean {
|
||||
return user !in hiddenUsers() // if user hasn't hided this author
|
||||
return !isHidden(user) // if user hasn't hided this author
|
||||
&& user.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
|
||||
&& user.reportsBy( userProfile().follows ).size < 5
|
||||
&& user.countReportAuthorsBy( userProfile().follows ) < 5
|
||||
}
|
||||
|
||||
fun isAcceptableDirect(note: Note): Boolean {
|
||||
return note.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
|
||||
&& note.reportsBy( userProfile().follows ).size < 5 // if it has 5 reports by reliable users
|
||||
&& note.countReportAuthorsBy( userProfile().follows ) < 5 // if it has 5 reports by reliable users
|
||||
}
|
||||
|
||||
fun isAcceptable(note: Note): Boolean {
|
||||
return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author
|
||||
&& isAcceptableDirect(note)
|
||||
&& (note.event !is RepostEvent
|
||||
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
|
||||
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
|
||||
) // is not a reaction about a blocked post
|
||||
}
|
||||
|
||||
@@ -408,14 +523,68 @@ class Account(
|
||||
innerReports).toSet()
|
||||
}
|
||||
|
||||
fun saveRelayList(value: List<NewRelayListViewModel.Relay>) {
|
||||
fun saveRelayList(value: List<RelaySetupInfo>) {
|
||||
localRelays = value.toSet()
|
||||
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
|
||||
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
init {
|
||||
backupContactList?.let {
|
||||
println("Loading saved contacts ${it.toJson()}")
|
||||
if (userProfile().latestContactList == null) {
|
||||
LocalCache.consume(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Observes relays to restart connections
|
||||
userProfile().live().relays.observeForever {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
reconnectIfRelaysHaveChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// saves contact list for the next time.
|
||||
userProfile().live().follows.observeForever {
|
||||
updateContactListTo(userProfile().latestContactList)
|
||||
}
|
||||
|
||||
// imports transient blocks due to spam.
|
||||
LocalCache.antiSpam.liveSpam.observeForever {
|
||||
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 !in userProfile().follows) {
|
||||
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
postValue(AccountState(account))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.lifecycle.LiveData
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
|
||||
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
|
||||
|
||||
class AntiSpamFilter {
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event): Boolean {
|
||||
val idHex = event.id
|
||||
|
||||
// if short message, ok
|
||||
if (event.content.length < 50) return false
|
||||
|
||||
// double list strategy:
|
||||
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
|
||||
|
||||
// Considers tags so that same replies to different people don't count.
|
||||
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
|
||||
|
||||
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${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
|
||||
|
||||
liveSpam.invalidateData()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
recentMessages.put(hash, idHex)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter): LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class AntiSpamState(val cache: AntiSpamFilter) {
|
||||
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class Channel(val id: ByteArray) {
|
||||
val idHex = id.toHexKey()
|
||||
val idDisplayHex = id.toShortenHex()
|
||||
|
||||
class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
@@ -17,11 +15,14 @@ class Channel(val id: ByteArray) {
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
fun idNote() = id().toNote()
|
||||
fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return info.name ?: idDisplayHex
|
||||
return info.name ?: idDisplayNote()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addNote(note: Note) {
|
||||
notes[note.idHex] = note
|
||||
}
|
||||
@@ -34,9 +35,9 @@ class Channel(val id: ByteArray) {
|
||||
live.refresh()
|
||||
}
|
||||
|
||||
fun profilePicture(): String {
|
||||
fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) info.picture = null
|
||||
return info.picture ?: "https://robohash.org/${idHex}.png"
|
||||
return info.picture
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
@@ -47,8 +48,21 @@ class Channel(val id: ByteArray) {
|
||||
// Observers line up here.
|
||||
val live: ChannelLiveData = ChannelLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.refresh()
|
||||
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
|
||||
val important = notes.values
|
||||
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
.take(1000)
|
||||
.toSet()
|
||||
|
||||
val toBeRemoved = notes.values.filter { it !in important }.toSet()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
notes.remove(it.idHex)
|
||||
}
|
||||
|
||||
return toBeRemoved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,12 +74,12 @@ class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleEventDataSource.add(channel.idHex)
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleEventDataSource.remove(channel.idHex)
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.util.regex.Pattern
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.bechToBytes
|
||||
@@ -41,8 +40,44 @@ fun decodePublicKey(key: String): ByteArray {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
} else if (key.startsWith("npub")) {
|
||||
key.bechToBytes()
|
||||
} else if (key.startsWith("note")) {
|
||||
key.bechToBytes()
|
||||
} else { //if (pattern.matcher(key).matches()) {
|
||||
//} else {
|
||||
Hex.decode(key)
|
||||
}
|
||||
}
|
||||
|
||||
data class DirtyKeyInfo(val type: String, val keyHex: String, val restOfWord: String)
|
||||
|
||||
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
var key = mightBeAKey
|
||||
if (key.startsWith("nostr:", true)) {
|
||||
key = key.substring("nostr:".length)
|
||||
}
|
||||
|
||||
key = key.removePrefix("@")
|
||||
|
||||
if (key.length < 63)
|
||||
return null
|
||||
|
||||
try {
|
||||
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
if (key.startsWith("nsec1", true)) {
|
||||
return DirtyKeyInfo("npub", Persona(privKey = keyB32.bechToBytes()).pubKey.toHexKey(), restOfWord)
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
return DirtyKeyInfo("npub", keyB32.bechToBytes().toHexKey(), restOfWord)
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord)
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -5,29 +5,43 @@ import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.DeletionEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
import nostr.postr.events.RecommendRelayEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import nostr.postr.toHex
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.toNpub
|
||||
|
||||
|
||||
@@ -36,9 +50,22 @@ object LocalCache {
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.readerFor(UserMetadata::class.java)
|
||||
|
||||
val antiSpam = AntiSpamFilter()
|
||||
|
||||
val users = ConcurrentHashMap<HexKey, User>()
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
val channels = ConcurrentHashMap<HexKey, Channel>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>()
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
return try {
|
||||
val checkHex = Hex.decode(key).toNpub() // Checks if this is a valid Hex
|
||||
getOrCreateUser(key)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create user: $key", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateUser(key: HexKey): User {
|
||||
@@ -49,6 +76,19 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun checkGetOrCreateNote(key: String): Note? {
|
||||
if (key.startsWith("naddr1")) {
|
||||
return checkGetOrCreateAddressableNote(key)
|
||||
}
|
||||
return try {
|
||||
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
|
||||
getOrCreateNote(key)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create note: $key", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateNote(idHex: String): Note {
|
||||
return notes[idHex] ?: run {
|
||||
@@ -58,31 +98,65 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun checkGetOrCreateChannel(key: String): Channel? {
|
||||
return try {
|
||||
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
|
||||
getOrCreateChannel(key)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateChannel(key: String): Channel {
|
||||
return channels[key] ?: run {
|
||||
val answer = Channel(key.toByteArray())
|
||||
val answer = Channel(key)
|
||||
channels.put(key, answer)
|
||||
answer
|
||||
}
|
||||
}
|
||||
|
||||
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? {
|
||||
return try {
|
||||
val addr = ATag.parse(key)
|
||||
if (addr != null)
|
||||
getOrCreateAddressableNote(addr)
|
||||
else
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
|
||||
return addressables[key.toNAddr()] ?: run {
|
||||
val answer = AddressableNote(key)
|
||||
answer.author = checkGetOrCreateUser(key.pubKeyHex)
|
||||
addressables.put(key.toNAddr(), answer)
|
||||
answer
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: MetadataEvent) {
|
||||
// new event
|
||||
val oldUser = getOrCreateUser(event.pubKey.toHexKey())
|
||||
if (event.createdAt > oldUser.updatedMetadataAt) {
|
||||
val oldUser = getOrCreateUser(event.pubKey)
|
||||
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
|
||||
val newUser = try {
|
||||
metadataParser.readValue<UserMetadata>(ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), UserMetadata::class.java)
|
||||
metadataParser.readValue(
|
||||
ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)),
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}")
|
||||
return
|
||||
}
|
||||
|
||||
oldUser.updateUserInfo(newUser, event.createdAt)
|
||||
oldUser.latestMetadata = event
|
||||
|
||||
oldUser.updateUserInfo(newUser, event)
|
||||
//Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
|
||||
} else {
|
||||
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -94,21 +168,29 @@ object LocalCache {
|
||||
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
|
||||
}
|
||||
|
||||
fun consume(event: TextNoteEvent, relay: Relay? = null) {
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
fun consume(event: TextNoteEvent, relay: Relay? = null) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelay(relay, event.createdAt)
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val mentions = event.mentions.map { getOrCreateUser(it) }
|
||||
val replyTo = event.replyTos.map { getOrCreateNote(it) }
|
||||
if (antiSpam.isSpam(event)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, mentions, replyTo)
|
||||
|
||||
@@ -133,30 +215,180 @@ object LocalCache {
|
||||
refreshObservers()
|
||||
}
|
||||
|
||||
fun consume(event: LongTextNoteEvent, relay: Relay?) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id == event.id) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, mentions, replyTo)
|
||||
|
||||
author.addNote(note)
|
||||
|
||||
// Adds notifications to users.
|
||||
mentions.forEach {
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
replyTo.forEach {
|
||||
it.author?.addTaggedPost(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id == event.id) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList<User>(), emptyList<Note>())
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeProfilesEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id == event.id) return
|
||||
|
||||
val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList(), replyTo)
|
||||
|
||||
author.updateAcceptedBadges(note)
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeAwardEvent) {
|
||||
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 awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, awardees, awardDefinition)
|
||||
|
||||
// Adds notifications to users.
|
||||
awardees.forEach {
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
awardees.forEach {
|
||||
it.addBadgeAward(note)
|
||||
}
|
||||
|
||||
// Replies of an Badge Definition are Award Events
|
||||
awardDefinition.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
|
||||
private fun findCitations(event: Event): Set<String> {
|
||||
var citations = mutableSetOf<String>()
|
||||
// Removes citations from replies:
|
||||
val matcher = tagSearch.matcher(event.content)
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { event.tags[it.toInt()] }
|
||||
if (tag != null && tag[0] == "e") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
}
|
||||
}
|
||||
return citations
|
||||
}
|
||||
|
||||
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
|
||||
val repliesTo = event.replyTos()
|
||||
if (repliesTo.isEmpty()) return repliesTo
|
||||
|
||||
val citations = findCitations(event)
|
||||
|
||||
return if (citations.isEmpty()) {
|
||||
repliesTo
|
||||
} else {
|
||||
repliesTo.filter { it !in citations }
|
||||
}
|
||||
}
|
||||
|
||||
private fun replyToWithoutCitations(event: LongTextNoteEvent): List<String> {
|
||||
val repliesTo = event.replyTos()
|
||||
if (repliesTo.isEmpty()) return repliesTo
|
||||
|
||||
val citations = findCitations(event)
|
||||
|
||||
return if (citations.isEmpty()) {
|
||||
repliesTo
|
||||
} else {
|
||||
repliesTo.filter { it !in citations }
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: RecommendRelayEvent) {
|
||||
//Log.d("RR", event.toJson())
|
||||
}
|
||||
|
||||
fun consume(event: ContactListEvent) {
|
||||
val user = getOrCreateUser(event.pubKey.toHexKey())
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
val follows = event.follows()
|
||||
|
||||
if (event.createdAt > user.updatedFollowsAt && !follows.isNullOrEmpty()) {
|
||||
// Saves relay list only if it's a user that is currently been seen
|
||||
user.latestContactList = event
|
||||
|
||||
if (event.createdAt > user.updatedFollowsAt) {
|
||||
//Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
|
||||
user.updateFollows(
|
||||
event.follows.map {
|
||||
follows.map {
|
||||
try {
|
||||
val pubKey = decodePublicKey(it.pubKeyHex)
|
||||
getOrCreateUser(pubKey.toHexKey())
|
||||
} catch (e: Exception) {
|
||||
println("Could not parse Hex key: ${it.pubKeyHex}")
|
||||
println("UpdateFollows: " + event.toJson())
|
||||
e.printStackTrace()
|
||||
Log.w("ContactList Parser", "Ignoring: Could not parse Hex key: ${it.pubKeyHex} in ${event.toJson()}")
|
||||
//e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}.filterNotNull().toSet(),
|
||||
event.createdAt
|
||||
)
|
||||
|
||||
// Saves relay list only if it's a user that is currently been seen
|
||||
try {
|
||||
if (event.content.isNotEmpty()) {
|
||||
val relays: Map<String, ContactListEvent.ReadWrite> =
|
||||
@@ -168,31 +400,32 @@ object LocalCache {
|
||||
user.updateRelays(relays)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Relay List Parser","Relay import issue ${e.message}", e)
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
user.latestContactList = event
|
||||
Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PrivateDmEvent, relay: Relay?) {
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelay(relay, event.createdAt)
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val recipient = event.recipientPubKey?.let { getOrCreateUser(it.toHexKey()) }
|
||||
val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) }
|
||||
|
||||
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||
|
||||
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }
|
||||
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateUser(it) }
|
||||
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateNote(it) }
|
||||
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) }
|
||||
|
||||
note.loadEvent(event, author, mentions, repliesTo)
|
||||
|
||||
@@ -205,20 +438,55 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: DeletionEvent) {
|
||||
//Log.d("DEL", event.toJson())
|
||||
var deletedAtLeastOne = false
|
||||
|
||||
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
|
||||
deleteNote.mentions?.forEach { user ->
|
||||
user.removeTaggedPost(deleteNote)
|
||||
user.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
deleteNote.replyTo?.forEach { replyingNote ->
|
||||
replyingNote.author?.removeTaggedPost(deleteNote)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
deleteNote.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeReply(deleteNote)
|
||||
masterNote.removeBoost(deleteNote)
|
||||
masterNote.removeReaction(deleteNote)
|
||||
masterNote.removeZap(deleteNote)
|
||||
masterNote.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
notes.remove(deleteNote.idHex)
|
||||
|
||||
deletedAtLeastOne = true
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedAtLeastOne) {
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: RepostEvent) {
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
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.toHexKey())
|
||||
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
|
||||
val repliesTo = event.boostedPost.map { getOrCreateNote(it) }
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, mentions, repliesTo)
|
||||
|
||||
@@ -242,14 +510,15 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: ReactionEvent) {
|
||||
val note = getOrCreateNote(event.id.toHexKey())
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
|
||||
val repliesTo = event.originalPost.map { getOrCreateNote(it) }
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, mentions, repliesTo)
|
||||
|
||||
@@ -286,15 +555,21 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: ReportEvent) {
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
fun consume(event: ReportEvent, relay: Relay?) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
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
|
||||
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
val mentions = event.reportedAuthor.map { getOrCreateUser(it) }
|
||||
val repliesTo = event.reportedPost.map { getOrCreateNote(it) }
|
||||
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) }
|
||||
val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, mentions, repliesTo)
|
||||
|
||||
@@ -313,15 +588,14 @@ object LocalCache {
|
||||
fun consume(event: ChannelCreateEvent) {
|
||||
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
|
||||
// new event
|
||||
val oldChannel = getOrCreateChannel(event.id.toHex())
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
val oldChannel = getOrCreateChannel(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
if (event.createdAt > oldChannel.updatedMetadataAt) {
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
val note = getOrCreateNote(event.id)
|
||||
oldChannel.addNote(note)
|
||||
note.channel = oldChannel
|
||||
note.loadEvent(event, author, emptyList(), emptyList())
|
||||
|
||||
refreshObservers()
|
||||
@@ -330,20 +604,21 @@ object LocalCache {
|
||||
// older data, does nothing
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: ChannelMetadataEvent) {
|
||||
val channelId = event.channel()
|
||||
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
|
||||
if (event.channel.isNullOrBlank()) return
|
||||
if (channelId.isNullOrBlank()) return
|
||||
|
||||
// new event
|
||||
val oldChannel = getOrCreateChannel(event.channel)
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
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)
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
val note = getOrCreateNote(event.id)
|
||||
oldChannel.addNote(note)
|
||||
note.channel = oldChannel
|
||||
note.loadEvent(event, author, emptyList(), emptyList())
|
||||
|
||||
refreshObservers()
|
||||
@@ -354,29 +629,37 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: ChannelMessageEvent, relay: Relay?) {
|
||||
if (event.channel.isNullOrBlank()) return
|
||||
val channelId = event.channel()
|
||||
|
||||
val channel = getOrCreateChannel(event.channel)
|
||||
if (channelId.isNullOrBlank()) return
|
||||
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
val channel = checkGetOrCreateChannel(channelId) ?: return
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
channel.addNote(note)
|
||||
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelay(relay, event.createdAt)
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val mentions = event.mentions.map { getOrCreateUser(it) }
|
||||
val replyTo = event.replyTos
|
||||
.map { getOrCreateNote(it) }
|
||||
if (antiSpam.isSpam(event)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val replyTo = event.replyTos()
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
.filter { it.event !is ChannelCreateEvent }
|
||||
|
||||
note.channel = channel
|
||||
note.loadEvent(event, author, mentions, replyTo)
|
||||
|
||||
//Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
|
||||
@@ -405,41 +688,242 @@ object LocalCache {
|
||||
|
||||
}
|
||||
|
||||
fun consume(event: LnZapEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
|
||||
((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>())
|
||||
|
||||
note.loadEvent(event, author, mentions, repliesTo)
|
||||
|
||||
if (zapRequest == null) {
|
||||
Log.e("ZP","Zap Request not found. Unable to process Zap {${event.toJson()}}")
|
||||
return
|
||||
}
|
||||
|
||||
//Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Adds notifications to users.
|
||||
mentions.forEach {
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
repliesTo.forEach {
|
||||
it.author?.addTaggedPost(note)
|
||||
}
|
||||
|
||||
repliesTo.forEach {
|
||||
it.addZap(zapRequest, note)
|
||||
}
|
||||
mentions.forEach {
|
||||
it.addZap(zapRequest, note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: LnZapRequestEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, mentions, repliesTo)
|
||||
|
||||
//Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Adds notifications to users.
|
||||
mentions.forEach {
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
repliesTo.forEach {
|
||||
it.author?.addTaggedPost(note)
|
||||
}
|
||||
|
||||
repliesTo.forEach {
|
||||
it.addZap(note, null)
|
||||
}
|
||||
mentions.forEach {
|
||||
it.addZap(note, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun findUsersStartingWith(username: String): List<User> {
|
||||
return users.values.filter {
|
||||
it.info.anyNameStartsWith(username)
|
||||
(it.anyNameStartsWith(username))
|
||||
|| it.pubkeyHex.startsWith(username, true)
|
||||
|| it.pubkey.toNpub().startsWith(username, true)
|
||||
|| it.pubkeyNpub().startsWith(username, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
return notes.values.filter {
|
||||
(it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false)
|
||||
(it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false)
|
||||
|| (it.event is ChannelMessageEvent && it.event?.content?.contains(text, true) ?: false)
|
||||
|| it.idHex.startsWith(text, true)
|
||||
|| it.id.toNote().startsWith(text, true)
|
||||
|| 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.idHex.startsWith(text, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun findChannelsStartingWith(text: String): List<Channel> {
|
||||
return channels.values.filter {
|
||||
it.anyNameStartsWith(text)
|
||||
it.anyNameStartsWith(text)
|
||||
|| it.idHex.startsWith(text, true)
|
||||
|| it.id.toNote().startsWith(text, true)
|
||||
|| it.idNote().startsWith(text, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanObservers() {
|
||||
notes.forEach {
|
||||
it.value.clearLive()
|
||||
}
|
||||
|
||||
users.forEach {
|
||||
it.value.clearLive()
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account) {
|
||||
channels.forEach {
|
||||
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
|
||||
|
||||
toBeRemoved.forEach {
|
||||
notes.remove(it.idHex)
|
||||
// Doesn't need to clean up the replies and mentions.. Too small to matter.
|
||||
|
||||
// reverts the add
|
||||
it.mentions?.forEach { user ->
|
||||
user.removeTaggedPost(it)
|
||||
}
|
||||
it.replyTo?.forEach { replyingNote ->
|
||||
replyingNote.author?.removeTaggedPost(it)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { replyingNote ->
|
||||
it.removeReply(it)
|
||||
}
|
||||
}
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneNonFollows(account: Account) {
|
||||
val follows = account.userProfile().follows
|
||||
val knownPMs = account.userProfile().privateChatrooms.filter {
|
||||
account.userProfile().hasSentMessagesTo(it.key) && account.isAcceptable(it.key)
|
||||
}
|
||||
|
||||
val followsFollow = follows.map {
|
||||
it.follows
|
||||
}.flatten()
|
||||
|
||||
val followSet = follows.plus(knownPMs).plus(account.userProfile()).plus(followsFollow)
|
||||
|
||||
val toBeRemoved = notes
|
||||
.filter {
|
||||
(it.value.author == null || it.value.author!! !in followSet) && it.value.event?.kind == TextNoteEvent.kind && it.value.liveSet?.isInUse() != true
|
||||
}
|
||||
|
||||
toBeRemoved.forEach {
|
||||
notes.remove(it.key)
|
||||
}
|
||||
|
||||
val toBeRemovedUsers = users
|
||||
.filter {
|
||||
(it.value !in followSet) && it.value.liveSet?.isInUse() != true
|
||||
}
|
||||
|
||||
toBeRemovedUsers.forEach {
|
||||
users.remove(it.key)
|
||||
}
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they came from NonFollows")
|
||||
println("PRUNE: ${toBeRemovedUsers.size} users removed because are NonFollows")
|
||||
}
|
||||
|
||||
fun pruneHiddenMessages(account: Account) {
|
||||
val toBeRemoved = account.hiddenUsers.map {
|
||||
(users[it]?.notes ?: emptySet())
|
||||
}.flatten()
|
||||
|
||||
account.hiddenUsers.forEach {
|
||||
users[it]?.clearNotes()
|
||||
}
|
||||
|
||||
toBeRemoved.forEach {
|
||||
it.author?.removeNote(it)
|
||||
|
||||
// reverts the add
|
||||
it.mentions?.forEach { user ->
|
||||
user.removeTaggedPost(it)
|
||||
}
|
||||
it.replyTo?.forEach { replyingNote ->
|
||||
replyingNote.author?.removeTaggedPost(it)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeReply(it)
|
||||
masterNote.removeBoost(it)
|
||||
masterNote.removeReaction(it)
|
||||
masterNote.removeZap(it)
|
||||
masterNote.removeReport(it)
|
||||
}
|
||||
|
||||
notes.remove(it.idHex)
|
||||
}
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.refresh()
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheLiveData(val cache: LocalCache): LiveData<LocalCacheState>(LocalCacheState(cache)) {
|
||||
fun refresh() {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(50)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(LocalCacheState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,44 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
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.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Collections
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import java.util.Date
|
||||
import java.util.regex.Matcher
|
||||
import java.util.regex.Pattern
|
||||
import nostr.postr.events.Event
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
|
||||
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
|
||||
class Note(val idHex: String) {
|
||||
// These fields are always available.
|
||||
// They are immutable
|
||||
val id = Hex.decode(idHex)
|
||||
val idDisplayNote = id.toNote().toShortenHex()
|
||||
|
||||
class AddressableNote(val address: ATag): Note(address.toNAddr()) {
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
override fun address() = address
|
||||
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt
|
||||
}
|
||||
|
||||
open class Note(val idHex: String) {
|
||||
// These fields are only available after the Text Note event is received.
|
||||
// They are immutable after that.
|
||||
var event: Event? = null
|
||||
@@ -43,23 +54,40 @@ class Note(val idHex: String) {
|
||||
private set
|
||||
var boosts = setOf<Note>()
|
||||
private set
|
||||
var reports = setOf<Note>()
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
private set
|
||||
|
||||
var channel: Channel? = null
|
||||
|
||||
var lastReactionsDownloadTime: Long? = null
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
open fun idNote() = id().toNote()
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun channel(): Channel? {
|
||||
val channelHex =
|
||||
(event as? ChannelMessageEvent)?.channel() ?:
|
||||
(event as? ChannelMetadataEvent)?.channel() ?:
|
||||
(event as? ChannelCreateEvent)?.let { it.id }
|
||||
|
||||
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
open fun address() = (event as? LongTextNoteEvent)?.address()
|
||||
|
||||
open fun createdAt() = event?.createdAt
|
||||
|
||||
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
|
||||
this.event = event
|
||||
this.author = author
|
||||
this.mentions = mentions
|
||||
this.replyTo = replyTo
|
||||
|
||||
live.invalidateData()
|
||||
liveSet?.metadata?.invalidateData()
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
@@ -67,63 +95,124 @@ class Note(val idHex: String) {
|
||||
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
|
||||
}
|
||||
|
||||
fun replyLevelSignature(): String {
|
||||
/**
|
||||
* This method caches signatures during each execution to avoid recalculation in longer threads
|
||||
*/
|
||||
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
|
||||
val replyTo = replyTo
|
||||
if (replyTo == null || replyTo.isEmpty()) {
|
||||
return "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
|
||||
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
}
|
||||
|
||||
return replyTo
|
||||
.map { it.replyLevelSignature() }
|
||||
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
|
||||
.map {
|
||||
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
}
|
||||
|
||||
fun replyLevel(): Int {
|
||||
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
|
||||
val replyTo = replyTo
|
||||
if (replyTo == null || replyTo.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return replyTo.maxOf {
|
||||
it.replyLevel()
|
||||
cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) }
|
||||
} + 1
|
||||
}
|
||||
|
||||
fun addReply(note: Note) {
|
||||
if (note !in replies) {
|
||||
replies = replies + note
|
||||
liveReplies.invalidateData()
|
||||
liveSet?.replies?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReply(note: Note) {
|
||||
replies = replies - note
|
||||
liveSet?.replies?.invalidateData()
|
||||
}
|
||||
fun removeBoost(note: Note) {
|
||||
boosts = boosts - note
|
||||
liveSet?.boosts?.invalidateData()
|
||||
}
|
||||
fun removeReaction(note: Note) {
|
||||
reactions = reactions - note
|
||||
liveSet?.reactions?.invalidateData()
|
||||
}
|
||||
|
||||
fun removeReport(deleteNote: Note) {
|
||||
val author = deleteNote.author ?: return
|
||||
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZap(note: Note) {
|
||||
if (zaps[note] != null) {
|
||||
zaps = zaps.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zaps.containsValue(note)) {
|
||||
val toRemove = zaps.filterValues { it == note }
|
||||
zaps = zaps.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
liveBoosts.invalidateData()
|
||||
liveSet?.boosts?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (note !in reactions) {
|
||||
reactions = reactions + note
|
||||
liveReactions.invalidateData()
|
||||
liveSet?.reactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
if (note !in reports) {
|
||||
reports = reports + note
|
||||
liveReports.invalidateData()
|
||||
val author = note.author ?: return
|
||||
|
||||
if (author !in reports.keys) {
|
||||
reports = reports + Pair(author, setOf(note))
|
||||
liveSet?.reports?.invalidateData()
|
||||
} else if (reports[author]?.contains(note) == false) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
liveSet?.reports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: Relay) {
|
||||
if (relay.url !in relays) {
|
||||
relays = relays + relay.url
|
||||
liveRelays.invalidateData()
|
||||
liveSet?.relays?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun isZappedBy(user: User): Boolean {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any { it.key.author == user }
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
return reactions.any { it.author == user }
|
||||
}
|
||||
@@ -132,18 +221,54 @@ class Note(val idHex: String) {
|
||||
return boosts.any { it.author == user }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): List<Note> {
|
||||
return reports.filter { it.author == user }
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
return reports[user] ?: emptySet()
|
||||
}
|
||||
|
||||
fun reportAuthorsBy(users: Set<User>): List<User> {
|
||||
return reports.keys.filter { it in users }
|
||||
}
|
||||
|
||||
fun countReportAuthorsBy(users: Set<User>): Int {
|
||||
return reports.keys.count { it in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<User>): List<Note> {
|
||||
return reports.filter { it.author in users }
|
||||
return reportAuthorsBy(users).mapNotNull {
|
||||
reports[it]
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.mapNotNull {
|
||||
it.amount
|
||||
}.sumOf { it }
|
||||
}
|
||||
|
||||
fun hasAnyReports(): Boolean {
|
||||
val dayAgo = Date().time / 1000 - 24*60*60
|
||||
return author?.reports?.filter { it.event?.createdAt ?: 0 > dayAgo }?.isNotEmpty() ?: false
|
||||
|| reports.isNotEmpty()
|
||||
return reports.isNotEmpty() ||
|
||||
(author?.reports?.values?.filter {
|
||||
it.firstOrNull { ( it.createdAt() ?: 0 ) > dayAgo } != null
|
||||
}?.isNotEmpty() ?: false)
|
||||
}
|
||||
|
||||
fun directlyCiteUsersHex(): Set<HexKey> {
|
||||
val matcher = tagSearch.matcher(event?.content ?: "")
|
||||
val returningList = mutableSetOf<String>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) }
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun directlyCiteUsers(): Set<User> {
|
||||
@@ -153,7 +278,9 @@ class Note(val idHex: String) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) }
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(LocalCache.getOrCreateUser(tag[1]))
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
returningList.add(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -173,38 +300,84 @@ class Note(val idHex: String) {
|
||||
return event is RepostEvent || replyTo == null || replyTo?.size == 0
|
||||
}
|
||||
|
||||
fun hasZapped(loggedIn: User): Boolean {
|
||||
return zaps.any { it.key.author == loggedIn }
|
||||
}
|
||||
|
||||
fun hasReacted(loggedIn: User, content: String): Boolean {
|
||||
return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null
|
||||
return reactedBy(loggedIn, content).isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasBoosted(loggedIn: User): Boolean {
|
||||
fun reactedBy(loggedIn: User, content: String): List<Note> {
|
||||
return reactions.filter { it.author == loggedIn && it.event?.content == content }
|
||||
}
|
||||
|
||||
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
|
||||
val currentTime = Date().time / 1000
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: NoteLiveData = NoteLiveData(this)
|
||||
fun boostedBy(loggedIn: User): List<Note> {
|
||||
return boosts.filter { it.author == loggedIn }
|
||||
}
|
||||
|
||||
val liveReactions: NoteLiveData = NoteLiveData(this)
|
||||
val liveBoosts: NoteLiveData = NoteLiveData(this)
|
||||
val liveReplies: NoteLiveData = NoteLiveData(this)
|
||||
val liveReports: NoteLiveData = NoteLiveData(this)
|
||||
val liveRelays: NoteLiveData = NoteLiveData(this)
|
||||
var liveSet: NoteLiveSet? = null
|
||||
|
||||
fun live(): NoteLiveSet {
|
||||
if (liveSet == null) {
|
||||
liveSet = NoteLiveSet(this)
|
||||
}
|
||||
return liveSet!!
|
||||
}
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NoteLiveSet(u: Note) {
|
||||
// Observers line up here.
|
||||
val metadata: NoteLiveData = NoteLiveData(u)
|
||||
|
||||
val reactions: NoteLiveData = NoteLiveData(u)
|
||||
val boosts: NoteLiveData = NoteLiveData(u)
|
||||
val replies: NoteLiveData = NoteLiveData(u)
|
||||
val reports: NoteLiveData = NoteLiveData(u)
|
||||
val relays: NoteLiveData = NoteLiveData(u)
|
||||
val zaps: NoteLiveData = NoteLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.hasObservers()
|
||||
|| reactions.hasObservers()
|
||||
|| boosts.hasObservers()
|
||||
|| replies.hasObservers()
|
||||
|| reports.hasObservers()
|
||||
|| relays.hasObservers()
|
||||
|| zaps.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,12 +387,21 @@ class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleEventDataSource.add(note.idHex)
|
||||
if (note is AddressableNote) {
|
||||
NostrSingleEventDataSource.addAddress(note)
|
||||
} else {
|
||||
NostrSingleEventDataSource.add(note)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleEventDataSource.remove(note.idHex)
|
||||
if (note is AddressableNote) {
|
||||
NostrSingleEventDataSource.removeAddress(note)
|
||||
} else {
|
||||
NostrSingleEventDataSource.remove(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
|
||||
data class RelaySetupInfo(
|
||||
val url: String,
|
||||
val read: Boolean,
|
||||
val write: Boolean,
|
||||
val errorCount: Int = 0,
|
||||
val downloadCount: Int = 0,
|
||||
val uploadCount: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class ThreadAssembler {
|
||||
|
||||
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
testedNotes.add(note)
|
||||
|
||||
val markedAsRoot = note.event?.tags?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
// recursive
|
||||
val roots = note.replyTo?.map {
|
||||
if (it !in testedNotes)
|
||||
searchRoot(it, testedNotes)
|
||||
else
|
||||
null
|
||||
}?.filterNotNull()
|
||||
|
||||
if (roots != null && roots.isNotEmpty()) {
|
||||
return roots[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
val (result, elapsed) = measureTimedValue {
|
||||
val note = if (noteId.startsWith("naddr")) {
|
||||
val aTag = ATag.parse(noteId)
|
||||
if (aTag != null)
|
||||
LocalCache.getOrCreateAddressableNote(aTag)
|
||||
else
|
||||
return emptySet()
|
||||
} else {
|
||||
LocalCache.getOrCreateNote(noteId)
|
||||
}
|
||||
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = mutableSetOf<Note>()
|
||||
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
|
||||
loadDown(threadRoot, thread)
|
||||
|
||||
thread.toSet()
|
||||
} else {
|
||||
setOf(note)
|
||||
}
|
||||
}
|
||||
|
||||
println("Model Refresh: Thread loaded in ${elapsed}")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun loadDown(note: Note, thread: MutableSet<Note>) {
|
||||
if (note !in thread) {
|
||||
thread.add(note)
|
||||
|
||||
note.replies.forEach {
|
||||
loadDown(it, thread)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.ui.components.imageExtension
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtension
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -57,7 +56,7 @@ object UrlCachedPreviewer {
|
||||
fun preloadPreviewsFor(note: Note) {
|
||||
note.event?.content?.let {
|
||||
findUrlsInMessage(it).forEach {
|
||||
val removedParamsFromUrl = it.split("?")[0].toLowerCase()
|
||||
val removedParamsFromUrl = it.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Preload Images? Isn't this too heavy?
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
@@ -70,4 +69,4 @@ object UrlCachedPreviewer {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,35 +2,35 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import nostr.postr.toNpub
|
||||
|
||||
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
|
||||
|
||||
class Badges(val definition: Note, val awardees: Set<Note>)
|
||||
|
||||
class User(val pubkeyHex: String) {
|
||||
val pubkey = Hex.decode(pubkeyHex)
|
||||
val pubkeyDisplayHex = pubkey.toNpub().toShortenHex()
|
||||
var info: UserMetadata? = null
|
||||
|
||||
var info = UserMetadata()
|
||||
|
||||
var updatedMetadataAt: Long = 0;
|
||||
var updatedFollowsAt: Long = 0;
|
||||
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
|
||||
var follows = setOf<User>()
|
||||
private set
|
||||
@@ -39,10 +39,16 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
var notes = setOf<Note>()
|
||||
private set
|
||||
|
||||
var taggedPosts = setOf<Note>()
|
||||
private set
|
||||
|
||||
var reports = setOf<Note>()
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
var latestReportTime: Long = 0
|
||||
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays: Map<String, ContactListEvent.ReadWrite>? = null
|
||||
@@ -51,106 +57,203 @@ class User(val pubkeyHex: String) {
|
||||
var relaysBeingUsed = mapOf<String, RelayInfo>()
|
||||
private set
|
||||
|
||||
var messages = mapOf<User, Set<Note>>()
|
||||
var privateChatrooms = mapOf<User, Chatroom>()
|
||||
private set
|
||||
|
||||
var latestMetadataRequestEOSE: Long? = null
|
||||
var latestReportRequestEOSE: Long? = null
|
||||
var badgeAwards = setOf<Note>()
|
||||
private set
|
||||
|
||||
var acceptedBadges: AddressableNote? = null
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
|
||||
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
|
||||
}
|
||||
|
||||
fun bestUsername(): String? {
|
||||
return info.name?.ifBlank { null } ?: info.username?.ifBlank { null }
|
||||
return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun bestDisplayName(): String? {
|
||||
return info.displayName?.ifBlank { null } ?: info.display_name?.ifBlank { null }
|
||||
return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun profilePicture(): String {
|
||||
if (info.picture.isNullOrBlank()) info.picture = null
|
||||
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
|
||||
fun nip05(): String? {
|
||||
return info?.nip05?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (info?.picture.isNullOrBlank()) info?.picture = null
|
||||
return info?.picture
|
||||
}
|
||||
|
||||
fun follow(user: User, followedAt: Long) {
|
||||
follows = follows + user
|
||||
user.followers = user.followers + this
|
||||
|
||||
liveFollows.invalidateData()
|
||||
user.liveFollows.invalidateData()
|
||||
|
||||
listeners.forEach {
|
||||
it.onFollowsChange()
|
||||
}
|
||||
liveSet?.follows?.invalidateData()
|
||||
user.liveSet?.follows?.invalidateData()
|
||||
}
|
||||
|
||||
fun unfollow(user: User) {
|
||||
follows = follows - user
|
||||
user.followers = user.followers - this
|
||||
|
||||
liveFollows.invalidateData()
|
||||
user.liveFollows.invalidateData()
|
||||
liveSet?.follows?.invalidateData()
|
||||
user.liveSet?.follows?.invalidateData()
|
||||
}
|
||||
|
||||
updateSubscribers {
|
||||
it.onFollowsChange()
|
||||
fun follow(users: Set<User>, followedAt: Long) {
|
||||
follows = follows + users
|
||||
users.forEach {
|
||||
if (this !in it.followers && it.liveSet?.isInUse() == true) {
|
||||
it.followers = it.followers + this
|
||||
it.liveSet?.follows?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
liveSet?.follows?.invalidateData()
|
||||
}
|
||||
|
||||
fun unfollow(users: Set<User>) {
|
||||
follows = follows - users
|
||||
users.forEach {
|
||||
if (this in it.followers && it.liveSet?.isInUse() == true) {
|
||||
it.followers = it.followers - this
|
||||
it.liveSet?.follows?.invalidateData()
|
||||
}
|
||||
}
|
||||
liveSet?.follows?.invalidateData()
|
||||
}
|
||||
|
||||
fun addTaggedPost(note: Note) {
|
||||
if (note !in taggedPosts) {
|
||||
taggedPosts = taggedPosts + note
|
||||
updateSubscribers { it.onNewTaggedPosts() }
|
||||
// No need for Listener yet
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTaggedPost(note: Note) {
|
||||
taggedPosts = taggedPosts - note
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
if (note !in notes) {
|
||||
notes = notes + note
|
||||
updateSubscribers { it.onNewNotes() }
|
||||
// No need for Listener yet
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
notes = notes - note
|
||||
}
|
||||
|
||||
fun clearNotes() {
|
||||
notes = setOf<Note>()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
if (note !in reports) {
|
||||
reports = reports + note
|
||||
liveReports.invalidateData()
|
||||
val author = note.author ?: return
|
||||
|
||||
if (author !in reports.keys) {
|
||||
reports = reports + Pair(author, setOf(note))
|
||||
liveSet?.reports?.invalidateData()
|
||||
} else if (reports[author]?.contains(note) == false) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
liveSet?.reports?.invalidateData()
|
||||
}
|
||||
|
||||
val reportTime = note.createdAt() ?: 0
|
||||
if (reportTime > latestReportTime) {
|
||||
latestReportTime = reportTime
|
||||
}
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): List<Note> {
|
||||
return reports.filter { it.author == user }
|
||||
fun removeReport(deleteNote: Note) {
|
||||
val author = deleteNote.author ?: return
|
||||
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addBadgeAward(note: Note) {
|
||||
if (note !in badgeAwards) {
|
||||
badgeAwards = badgeAwards + note
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeBadgeAward(deleteNote: Note) {
|
||||
badgeAwards = badgeAwards - deleteNote
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
|
||||
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)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.mapNotNull {
|
||||
it.amount
|
||||
}.sumOf { it }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
return reports[user] ?: emptySet()
|
||||
}
|
||||
|
||||
fun reportAuthorsBy(users: Set<User>): List<User> {
|
||||
return reports.keys.filter { it in users }
|
||||
}
|
||||
|
||||
fun countReportAuthorsBy(users: Set<User>): Int {
|
||||
return reports.keys.count { it in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<User>): List<Note> {
|
||||
return reports.filter { it.author in users }
|
||||
return reportAuthorsBy(users).mapNotNull {
|
||||
reports[it]
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateChannel(user: User): Set<Note> {
|
||||
return messages[user] ?: run {
|
||||
val channel = setOf<Note>()
|
||||
messages = messages + Pair(user, channel)
|
||||
channel
|
||||
fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
return privateChatrooms[user] ?: run {
|
||||
val privateChatroom = Chatroom(setOf<Note>())
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
privateChatroom
|
||||
}
|
||||
}
|
||||
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
val channel = getOrCreateChannel(user)
|
||||
if (msg !in channel) {
|
||||
messages = messages + Pair(user, channel + msg)
|
||||
liveMessages.invalidateData()
|
||||
updateSubscribers { it.onNewMessage() }
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
data class RelayInfo (
|
||||
val url: String,
|
||||
var lastEvent: Long,
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
fun addRelay(relay: Relay, eventTime: Long) {
|
||||
fun addRelayBeingUsed(relay: Relay, eventTime: Long) {
|
||||
val here = relaysBeingUsed[relay.url]
|
||||
if (here == null) {
|
||||
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
|
||||
@@ -161,42 +264,47 @@ class User(val pubkeyHex: String) {
|
||||
here.counter++
|
||||
}
|
||||
|
||||
updateSubscribers { it.onNewRelayInfo() }
|
||||
liveRelayInfo.invalidateData()
|
||||
liveSet?.relayInfo?.invalidateData()
|
||||
}
|
||||
|
||||
|
||||
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
|
||||
val toBeAdded = newFollows - follows
|
||||
val toBeRemoved = follows - newFollows
|
||||
|
||||
toBeAdded.forEach {
|
||||
follow(it, updateAt)
|
||||
}
|
||||
toBeRemoved.forEach {
|
||||
unfollow(it)
|
||||
}
|
||||
follow(toBeAdded, updateAt)
|
||||
unfollow(toBeRemoved)
|
||||
|
||||
updatedFollowsAt = updateAt
|
||||
}
|
||||
|
||||
data class RelayMetadata(val read: Boolean, val write: Boolean, val activeTypes: Set<FeedType>)
|
||||
|
||||
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
|
||||
if (relays != relayUse) {
|
||||
relays = relayUse
|
||||
listeners.forEach {
|
||||
it.onRelayChange()
|
||||
// no need to test if relays are different. The Account will check for us.
|
||||
relays = relayUse
|
||||
liveSet?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
|
||||
info = newUserInfo
|
||||
info?.latestMetadata = latestMetadata
|
||||
info?.updatedMetadataAt = latestMetadata.createdAt
|
||||
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
|
||||
try {
|
||||
val url = String(Bech32.decodeBytes(newUserInfo.lud06!!, false).second)
|
||||
|
||||
val matcher = lnurlpPattern.matcher(url)
|
||||
while (matcher.find()) {
|
||||
val domain = matcher.group(2)
|
||||
val username = matcher.group(3)
|
||||
|
||||
info?.lud16 = "$username@$domain"
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Doesn't create errors.
|
||||
}
|
||||
}
|
||||
|
||||
liveRelays.invalidateData()
|
||||
}
|
||||
|
||||
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
|
||||
info = newUserInfo
|
||||
updatedMetadataAt = updateAt
|
||||
|
||||
liveMetadata.invalidateData()
|
||||
liveSet?.metadata?.invalidateData()
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean {
|
||||
@@ -204,66 +312,71 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = messages[user] ?: return false
|
||||
val messagesToUser = privateChatrooms[user] ?: return false
|
||||
|
||||
return messagesToUser.firstOrNull { this == it.author } != null
|
||||
return messagesToUser.roomMessages.any { this == it.author }
|
||||
}
|
||||
|
||||
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
|
||||
return reports.firstOrNull {
|
||||
it.author == loggedIn
|
||||
&& it.event is ReportEvent
|
||||
&& (it.event as ReportEvent).reportType.contains(type)
|
||||
return reports[loggedIn]?.firstOrNull() {
|
||||
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
|
||||
} != null
|
||||
}
|
||||
|
||||
// Model Observers
|
||||
private var listeners = setOf<Listener>()
|
||||
|
||||
fun subscribe(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
fun anyNameStartsWith(username: String): Boolean {
|
||||
return info?.anyNameStartsWith(username) ?: false
|
||||
}
|
||||
|
||||
fun unsubscribe(listener: Listener) {
|
||||
listeners = listeners.minus(listener)
|
||||
var liveSet: UserLiveSet? = null
|
||||
|
||||
fun live(): UserLiveSet {
|
||||
if (liveSet == null) {
|
||||
liveSet = UserLiveSet(this)
|
||||
}
|
||||
return liveSet!!
|
||||
}
|
||||
|
||||
abstract class Listener {
|
||||
open fun onRelayChange() = Unit
|
||||
open fun onFollowsChange() = Unit
|
||||
open fun onNewTaggedPosts() = Unit
|
||||
open fun onNewNotes() = Unit
|
||||
open fun onNewMessage() = Unit
|
||||
open fun onNewRelayInfo() = Unit
|
||||
open fun onNewReports() = Unit
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var modelHandlerWaiting = false
|
||||
@Synchronized
|
||||
fun updateSubscribers(on: (Listener) -> Unit) {
|
||||
if (modelHandlerWaiting) return
|
||||
|
||||
modelHandlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
listeners.forEach {
|
||||
on(it)
|
||||
}
|
||||
modelHandlerWaiting = false
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
|
||||
// UI Observers line up here.
|
||||
val liveFollows: UserLiveData = UserLiveData(this)
|
||||
val liveReports: UserLiveData = UserLiveData(this)
|
||||
val liveMessages: UserLiveData = UserLiveData(this)
|
||||
val liveRelays: UserLiveData = UserLiveData(this)
|
||||
val liveRelayInfo: UserLiveData = UserLiveData(this)
|
||||
val liveMetadata: UserLiveData = UserLiveData(this)
|
||||
|
||||
}
|
||||
|
||||
class UserLiveSet(u: User) {
|
||||
// UI Observers line up here.
|
||||
val follows: UserLiveData = UserLiveData(u)
|
||||
val reports: UserLiveData = UserLiveData(u)
|
||||
val messages: UserLiveData = UserLiveData(u)
|
||||
val relays: UserLiveData = UserLiveData(u)
|
||||
val relayInfo: UserLiveData = UserLiveData(u)
|
||||
val metadata: UserLiveData = UserLiveData(u)
|
||||
val zaps: UserLiveData = UserLiveData(u)
|
||||
val badges: UserLiveData = UserLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers()
|
||||
|| reports.hasObservers()
|
||||
|| messages.hasObservers()
|
||||
|| relays.hasObservers()
|
||||
|| relayInfo.hasObservers()
|
||||
|| metadata.hasObservers()
|
||||
|| zaps.hasObservers()
|
||||
|| badges.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
data class RelayInfo (
|
||||
val url: String,
|
||||
var lastEvent: Long,
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
data class Chatroom(var roomMessages: Set<Note>)
|
||||
|
||||
|
||||
class UserMetadata {
|
||||
var name: String? = null
|
||||
var username: String? = null
|
||||
@@ -273,7 +386,11 @@ class UserMetadata {
|
||||
var banner: String? = null
|
||||
var website: String? = null
|
||||
var about: String? = null
|
||||
|
||||
var nip05: String? = null
|
||||
var nip05Verified: Boolean = false
|
||||
var nip05LastVerificationTime: Long? = 0
|
||||
|
||||
var domain: String? = null
|
||||
var lud06: String? = null
|
||||
var lud16: String? = null
|
||||
@@ -283,26 +400,33 @@ class UserMetadata {
|
||||
var main_relay: String? = null
|
||||
var twitter: String? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0;
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
|
||||
.filter { it.startsWith(prefix, true) }.isNotEmpty()
|
||||
.any { it.startsWith(prefix, true) }
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,13 +436,14 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleUserDataSource.add(user.pubkeyHex)
|
||||
NostrSingleUserDataSource.add(user)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleUserDataSource.remove(user.pubkeyHex)
|
||||
NostrSingleUserDataSource.remove(user)
|
||||
}
|
||||
}
|
||||
|
||||
class UserState(val user: User)
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class Nip05Verifier {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
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]}"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchNip05JsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request: Request = Request.Builder().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 ${nip05}. Error: ${it.code}. Check if the server up and if the address ${nip05} is correct")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve ${url}. Check if the server up and if the address ${nip05} is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
onError("Could not resolve '${url}': ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchNip05Json(nip05,
|
||||
onSuccess = {
|
||||
val nip05url = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
val user = nip05.split("@")[0]
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
if (hexKey == null) {
|
||||
onError("Username not found in the NIP05 JSON")
|
||||
} else {
|
||||
onSuccess(hexKey)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,62 +2,127 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.bechToBytes
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class Nip19 {
|
||||
|
||||
enum class Type {
|
||||
USER, NOTE
|
||||
USER, NOTE, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
data class Return(val type: Type, val hex: String)
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
try {
|
||||
val key = uri?.removePrefix("nostr:")
|
||||
val key = uri?.removePrefix("nostr:") ?: return null
|
||||
|
||||
if (key != null) {
|
||||
val bytes = key.bechToBytes()
|
||||
if (key.startsWith("npub")) {
|
||||
return Return(Type.USER, bytes.toHexKey())
|
||||
}
|
||||
if (key.startsWith("note")) {
|
||||
return Return(Type.NOTE, bytes.toHexKey())
|
||||
}
|
||||
if (key.startsWith("nprofile")) {
|
||||
val tlv = parseTLV(bytes)
|
||||
val hex = tlv.get(0)?.get(0)?.toHexKey()
|
||||
if (hex != null)
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
if (key.startsWith("nevent")) {
|
||||
val tlv = parseTLV(bytes)
|
||||
val hex = tlv.get(0)?.get(0)?.toHexKey()
|
||||
if (hex != null)
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
val bytes = key.bechToBytes()
|
||||
if (key.startsWith("npub")) {
|
||||
return npub(bytes)
|
||||
} else if (key.startsWith("note")) {
|
||||
return note(bytes)
|
||||
} else if (key.startsWith("nprofile")) {
|
||||
return nprofile(bytes)
|
||||
} else if (key.startsWith("nevent")) {
|
||||
return nevent(bytes)
|
||||
} else if (key.startsWith("nrelay")) {
|
||||
return nrelay(bytes)
|
||||
} else if (key.startsWith("naddr")) {
|
||||
return naddr(bytes)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
println("Trying to Decode NIP19: ${uri}")
|
||||
e.printStackTrace()
|
||||
println("Issue trying to Decode NIP19 ${uri}: ${e.message}")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
|
||||
var result = mutableMapOf<Byte, MutableList<ByteArray>>()
|
||||
var rest = data
|
||||
while (rest.isNotEmpty()) {
|
||||
val t = rest[0]
|
||||
val l = rest[1]
|
||||
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
|
||||
rest = rest.sliceArray(IntRange(2 + l, rest.size-1))
|
||||
if (v.size < l) continue
|
||||
|
||||
if (!result.containsKey(t)) {
|
||||
result.put(t, mutableListOf())
|
||||
}
|
||||
result.get(t)?.add(v)
|
||||
}
|
||||
return result
|
||||
private fun npub(bytes: ByteArray): Return {
|
||||
return Return(Type.USER, bytes.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
private fun note(bytes: ByteArray): Return {
|
||||
return Return(Type.NOTE, bytes.toHexKey());
|
||||
}
|
||||
|
||||
private fun nprofile(bytes: ByteArray): Return? {
|
||||
val hex = parseTLV(bytes)
|
||||
.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
|
||||
private fun nevent(bytes: ByteArray): Return? {
|
||||
val hex = parseTLV(bytes)
|
||||
.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
|
||||
private fun nrelay(bytes: ByteArray): Return? {
|
||||
val relayUrl = parseTLV(bytes)
|
||||
.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
|
||||
return Return(Type.RELAY, relayUrl)
|
||||
}
|
||||
|
||||
private fun naddr(bytes: ByteArray): Return? {
|
||||
val tlv = parseTLV(bytes)
|
||||
|
||||
val d = tlv.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
|
||||
val relay = tlv.get(NIP19TLVTypes.RELAY.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
val author = tlv.get(NIP19TLVTypes.AUTHOR.id)
|
||||
?.get(0)
|
||||
?.toHexKey()
|
||||
|
||||
val kind = tlv.get(NIP19TLVTypes.KIND.id)
|
||||
?.get(0)
|
||||
?.let { toInt32(it) }
|
||||
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d")
|
||||
}
|
||||
}
|
||||
|
||||
// Classes should start with an uppercase letter in kotlin
|
||||
enum class NIP19TLVTypes(val id: Byte) {
|
||||
SPECIAL(0),
|
||||
RELAY(1),
|
||||
AUTHOR(2),
|
||||
KIND(3);
|
||||
}
|
||||
|
||||
fun toInt32(bytes: ByteArray): Int {
|
||||
require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
|
||||
return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
|
||||
}
|
||||
|
||||
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
|
||||
val result = mutableMapOf<Byte, MutableList<ByteArray>>()
|
||||
var rest = data
|
||||
while (rest.isNotEmpty()) {
|
||||
val t = rest[0]
|
||||
val l = rest[1]
|
||||
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
|
||||
rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
|
||||
if (v.size < l) continue
|
||||
|
||||
if (!result.containsKey(t)) {
|
||||
result[t] = mutableListOf()
|
||||
}
|
||||
result[t]?.add(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
|
||||
object NostrAccountDataSource: NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter {
|
||||
@@ -37,6 +40,17 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
@@ -50,6 +64,9 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind, BadgeAwardEvent.kind
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 200
|
||||
)
|
||||
@@ -57,22 +74,14 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
|
||||
|
||||
val accountChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val user = account.userProfile()
|
||||
|
||||
return LocalCache.notes.values
|
||||
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter()
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
|
||||
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var channel: com.vitorpamplona.amethyst.model.Channel? = null
|
||||
object NostrChannelDataSource: NostrDataSource("ChatroomFeed") {
|
||||
var channel: Channel? = null
|
||||
|
||||
fun loadMessagesBetween(channelId: String) {
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
@@ -33,11 +31,6 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
|
||||
|
||||
val messagesChannel = requestNewChannel()
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
+5
-12
@@ -2,20 +2,20 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
|
||||
object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
|
||||
object NostrChatroomDataSource: NostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, userId: String) {
|
||||
account = accountIn
|
||||
withUser = LocalCache.users[userId]
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createMessagesToMeFilter(): TypedFilter? {
|
||||
@@ -40,7 +40,7 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -54,13 +54,6 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
|
||||
|
||||
val inandoutChannel = requestNewChannel()
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val messages = account.userProfile().messages[withUser] ?: return emptyList()
|
||||
|
||||
return messages.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
|
||||
}
|
||||
+6
-23
@@ -1,16 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
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.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
|
||||
object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
|
||||
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createMessagesToMeFilter() = TypedFilter(
|
||||
@@ -38,7 +37,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList()
|
||||
@@ -48,7 +47,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
@@ -65,7 +64,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 1
|
||||
limit = 100 // Remember to consider spam that is being removed from the UI
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -73,22 +72,6 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
|
||||
|
||||
val chatroomListChannel = requestNewChannel()
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val messages = account.userProfile().messages
|
||||
val messagingWith = messages.keys.filter { account.isAcceptable(it) }
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull {
|
||||
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
val publicChannels = account.followingChannels().map {
|
||||
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val list = listOf(
|
||||
createMessagesToMeFilter(),
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
@@ -16,69 +21,84 @@ import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.Subscription
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.DeletionEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
import nostr.postr.events.RecommendRelayEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
abstract class NostrDataSource<T>(val debugName: String) {
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
private var eventCounter = mapOf<String, Int>()
|
||||
data class Counter(var counter:Int)
|
||||
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
println("AAA Count ${it.key}: ${it.value}")
|
||||
println("AAA Count ${it.key}: ${it.value.counter}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
if (subscriptionId in subscriptions.keys) {
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
val key = "${debugName} ${subscriptionId} ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
if (keyValue != null) {
|
||||
eventCounter = eventCounter + Pair(key, keyValue + 1)
|
||||
keyValue.counter++
|
||||
} else {
|
||||
eventCounter = eventCounter + Pair(key, 1)
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
}
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is BadgeAwardEvent -> LocalCache.consume(event)
|
||||
is BadgeDefinitionEvent -> LocalCache.consume(event)
|
||||
is BadgeProfilesEvent -> LocalCache.consume(event)
|
||||
is ChannelCreateEvent -> LocalCache.consume(event)
|
||||
is ChannelHideMessageEvent -> LocalCache.consume(event)
|
||||
is ChannelMessageEvent -> LocalCache.consume(event, relay)
|
||||
is ChannelMetadataEvent -> LocalCache.consume(event)
|
||||
is ChannelMuteUserEvent -> LocalCache.consume(event)
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
else -> when (event.kind) {
|
||||
RepostEvent.kind -> {
|
||||
val repostEvent = RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
|
||||
|
||||
repostEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(repostEvent)
|
||||
}
|
||||
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
|
||||
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
|
||||
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
is LnZapEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is LnZapRequestEvent -> LocalCache.consume(event)
|
||||
is LongTextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
is ReactionEvent -> LocalCache.consume(event)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ReportEvent -> LocalCache.consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,33 +130,15 @@ abstract class NostrDataSource<T>(val debugName: String) {
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Start")
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Stop")
|
||||
subscriptions.values.forEach { channel ->
|
||||
if (channel.typedFilters != null) // if it is active, close
|
||||
Client.close(channel.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadTop(): List<T> {
|
||||
val returningList = feed().take(100)
|
||||
|
||||
// prepare previews
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
loadPreviews(returningList)
|
||||
}
|
||||
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun loadPreviews(list: List<T>) {
|
||||
list.forEach {
|
||||
if (it is Note) {
|
||||
UrlCachedPreviewer.preloadPreviewsFor(it)
|
||||
}
|
||||
Client.close(channel.id)
|
||||
channel.typedFilters = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,17 +153,24 @@ abstract class NostrDataSource<T>(val debugName: String) {
|
||||
subscriptions = subscriptions.minus(subscription.id)
|
||||
}
|
||||
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateFilters() {
|
||||
if (handlerWaiting) return
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateFilters() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
delay(200)
|
||||
resetFiltersSuspend()
|
||||
handlerWaiting = false
|
||||
try {
|
||||
delay(200)
|
||||
resetFiltersSuspend()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,5 +221,4 @@ abstract class NostrDataSource<T>(val debugName: String) {
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
abstract fun feed(): List<T>
|
||||
}
|
||||
@@ -1,35 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
|
||||
lateinit var account: Account
|
||||
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
val globalFeedChannel = requestNewChannel()
|
||||
|
||||
override fun feed() = LocalCache.notes.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter {
|
||||
(it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) ||
|
||||
(it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty())
|
||||
}
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrHiddenAccountsDataSource: NostrDataSource<User>("HiddenAccounts") {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed() = account.hiddenUsers()
|
||||
|
||||
override fun updateChannelFilters() {}
|
||||
}
|
||||
@@ -1,42 +1,46 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import nostr.postr.toHex
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
|
||||
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
object cacheListener: User.Listener() {
|
||||
override fun onFollowsChange() {
|
||||
resetFilters()
|
||||
}
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().subscribe(cacheListener)
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.observeForever(cacheListener)
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().unsubscribe(cacheListener)
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.removeObserver(cacheListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.userProfile().follows
|
||||
|
||||
val followKeys = follows.map {
|
||||
it.pubkey.toHex().substring(0, 6)
|
||||
it.pubkeyHex.substring(0, 6)
|
||||
}
|
||||
|
||||
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
@@ -44,7 +48,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 400
|
||||
)
|
||||
@@ -53,16 +57,6 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
|
||||
|
||||
val followAccountChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val user = account.userProfile()
|
||||
|
||||
return LocalCache.notes.values
|
||||
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
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.RepostEvent
|
||||
import nostr.postr.JsonFilter
|
||||
|
||||
object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return account.userProfile().taggedPosts
|
||||
.filter { it.event != null }
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter {
|
||||
it.event !is ChannelCreateEvent
|
||||
&& it.event !is ChannelMetadataEvent
|
||||
}
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {}
|
||||
}
|
||||
+54
-27
@@ -1,17 +1,40 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
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.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import nostr.postr.bechToBytes
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
private var hexToWatch: String? = null
|
||||
object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {
|
||||
private var searchString: String? = null
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
val mySearchString = searchString
|
||||
if (mySearchString == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val hexToWatch = try {
|
||||
if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) {
|
||||
decodePublicKey(mySearchString).toHex()
|
||||
} else if (mySearchString.startsWith("note")) {
|
||||
mySearchString.bechToBytes().toHex()
|
||||
} else {
|
||||
mySearchString
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Usually when people add an incomplete npub or note.
|
||||
null
|
||||
}
|
||||
|
||||
if (hexToWatch == null) {
|
||||
return null
|
||||
}
|
||||
@@ -21,43 +44,47 @@ object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed"
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
)),
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
))
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20,
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val searchChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return emptyList<Note>()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
searchChannel.typedFilters = createAnythingWithIDFilter()
|
||||
}
|
||||
|
||||
fun search(eventId: String) {
|
||||
try {
|
||||
val hex = if (eventId.startsWith("npub") || eventId.startsWith("nsec")) {
|
||||
decodePublicKey(eventId).toHex()
|
||||
} else if (eventId.startsWith("note")) {
|
||||
eventId.bechToBytes().toHex()
|
||||
} else {
|
||||
eventId
|
||||
}
|
||||
hexToWatch = hex
|
||||
invalidateFilters()
|
||||
} catch (e: Exception) {
|
||||
// Usually when people add an incomplete npub or note.
|
||||
}
|
||||
fun search(searchString: String) {
|
||||
this.searchString = searchString
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
hexToWatch = null
|
||||
searchString = null
|
||||
}
|
||||
}
|
||||
+2
-7
@@ -1,14 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
|
||||
object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed") {
|
||||
object NostrSingleChannelDataSource: NostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): TypedFilter? {
|
||||
@@ -51,10 +50,6 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
|
||||
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
|
||||
+92
-26
@@ -1,24 +1,88 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
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.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import java.util.Date
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<String>()
|
||||
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
private var addressesToWatch = setOf<Note>()
|
||||
|
||||
private fun createTagToAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
|
||||
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = addressesToWatch.filter { it.event == null }
|
||||
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex.substring(0,8))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
|
||||
val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
|
||||
val reactionsToWatch = eventsToWatch
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
@@ -27,14 +91,14 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
val now = Date().time / 1000
|
||||
|
||||
return reactionsToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime;
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -45,14 +109,12 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = eventsToWatch
|
||||
.map { LocalCache.getOrCreateNote(it) }
|
||||
.filter { it.event == null }
|
||||
|
||||
val threadingEventsToLoad = eventsToWatch
|
||||
.map { LocalCache.getOrCreateNote(it) }
|
||||
.mapNotNull { it.replyTo }
|
||||
.flatten()
|
||||
.filter { it.event == null }
|
||||
.filter { it !is AddressableNote && it.event == null }
|
||||
|
||||
val interestedEvents =
|
||||
(directEventsToLoad + threadingEventsToLoad)
|
||||
@@ -68,8 +130,8 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
@@ -77,37 +139,41 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel() { time ->
|
||||
val singleEventChannel = requestNewChannel { time ->
|
||||
eventsToWatch.forEach {
|
||||
LocalCache.getOrCreateNote(it).lastReactionsDownloadTime = time
|
||||
it.lastReactionsDownloadTime = time
|
||||
}
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return synchronized(eventsToWatch) {
|
||||
eventsToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createTagToAddressFilter()
|
||||
|
||||
singleEventChannel.typedFilters = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
|
||||
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
fun add(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun remove(eventId: String) {
|
||||
fun remove(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun addAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun removeAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
|
||||
object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
|
||||
var usersToWatch = setOf<String>()
|
||||
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
|
||||
var usersToWatch = setOf<User>()
|
||||
|
||||
fun createUserFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.filter { LocalCache.getOrCreateUser(it).latestMetadata == null }.map {
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
@@ -34,7 +33,8 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it))
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
since = it.latestReportTime
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -46,25 +46,20 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return synchronized(usersToWatch) {
|
||||
usersToWatch.map {
|
||||
LocalCache.users[it]
|
||||
}.filterNotNull()
|
||||
}
|
||||
}
|
||||
val userChannelOnce = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userChannel.typedFilters = listOfNotNull(createUserFilter(), createUserReportFilter()).flatten()
|
||||
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
|
||||
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(userId: String) {
|
||||
usersToWatch = usersToWatch.plus(userId)
|
||||
fun add(user: User) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun remove(userId: String) {
|
||||
usersToWatch = usersToWatch.minus(userId)
|
||||
fun remove(user: User) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -1,118 +1,43 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
|
||||
object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
|
||||
private var eventsToWatch = setOf<String>()
|
||||
|
||||
fun createRepliesAndReactionsFilter(): TypedFilter? {
|
||||
if (eventsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
|
||||
tags = mapOf("e" to eventsToWatch.toList())
|
||||
)
|
||||
)
|
||||
}
|
||||
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
|
||||
private var eventToWatch: String? = null
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val nodes = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
|
||||
val threadToLoad = eventToWatch ?: return null
|
||||
|
||||
val eventsToLoad = nodes
|
||||
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
|
||||
.filter { it.event == null }
|
||||
.map { it.idHex.substring(0, 8) }
|
||||
|
||||
if (eventsToLoad.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
.map { it.idHex }
|
||||
.toSet()
|
||||
.ifEmpty { null } ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
ids = eventsToLoad
|
||||
ids = eventsToLoad.map { it.substring(0, 8) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadEventsChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
// Currently orders by date of each event, descending, at each level of the reply stack
|
||||
val order = compareByDescending<Note> { it.replyLevelSignature() }
|
||||
|
||||
return eventsToWatch.map {
|
||||
LocalCache.getOrCreateNote(it)
|
||||
}.sortedWith(order)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter(), createRepliesAndReactionsFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
val markedAsRoot = note.event?.tags?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
if (markedAsRoot != null) return LocalCache.getOrCreateNote(markedAsRoot)
|
||||
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
testedNotes.add(note)
|
||||
|
||||
// recursive
|
||||
val roots = note.replyTo?.map {
|
||||
if (it !in testedNotes)
|
||||
searchRoot(it, testedNotes)
|
||||
else
|
||||
null
|
||||
}?.filterNotNull()
|
||||
|
||||
if (roots != null && roots.isNotEmpty()) {
|
||||
return roots[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String) {
|
||||
val note = LocalCache.getOrCreateNote(noteId)
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = mutableListOf<Note>()
|
||||
val threadSet = mutableSetOf<Note>()
|
||||
|
||||
val threadRoot = searchRoot(note) ?: note
|
||||
|
||||
loadDown(threadRoot, thread, threadSet)
|
||||
|
||||
eventsToWatch = thread.map { it.idHex }.toSet()
|
||||
} else {
|
||||
eventsToWatch = setOf(noteId)
|
||||
}
|
||||
|
||||
val loadEventsChannel = requestNewChannel(){
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun loadDown(note: Note, thread: MutableList<Note>, threadSet: MutableSet<Note>) {
|
||||
if (note !in threadSet) {
|
||||
thread.add(note)
|
||||
threadSet.add(note)
|
||||
override fun updateChannelFilters() {
|
||||
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
note.replies.forEach {
|
||||
loadDown(it, thread, threadSet)
|
||||
}
|
||||
}
|
||||
fun loadThread(noteId: String?) {
|
||||
eventToWatch = noteId
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
+72
-37
@@ -1,82 +1,117 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
|
||||
lateinit var account: Account
|
||||
object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
user = LocalCache.getOrCreateUser(userId)
|
||||
fun loadUserProfile(userId: String?) {
|
||||
if (userId != null) {
|
||||
user = LocalCache.getOrCreateUser(userId)
|
||||
} else {
|
||||
user = null
|
||||
}
|
||||
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createUserInfoFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserPostsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
fun createUserPostsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
limit = 100
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
fun createUserReceivedZapsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowersFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(user!!.pubkeyHex))
|
||||
fun createFollowersFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAcceptedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createReceivedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeAwardEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 20
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val userInfoChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return user?.notes
|
||||
?.filter { account.isAcceptable(it) }
|
||||
?.sortedBy { it.event?.createdAt }
|
||||
?.reversed()
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userInfoChannel.typedFilters = listOf(
|
||||
userInfoChannel.typedFilters = listOfNotNull(
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter()
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter(),
|
||||
createAcceptedAwardsFilter(),
|
||||
createReceivedAwardsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
|
||||
object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFollowerFeed") {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
user = LocalCache.users[userId]
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return user?.followers?.filter { account.isAcceptable(it) } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
|
||||
object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFollowsFeed") {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
user = LocalCache.users[userId]
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return user?.follows?.filter { account.isAcceptable(it) } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.vitorpamplona.amethyst.service.lang
|
||||
|
||||
import android.util.LruCache
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.android.gms.tasks.Tasks
|
||||
import com.google.mlkit.nl.languageid.LanguageIdentification
|
||||
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||
import com.google.mlkit.nl.translate.Translation
|
||||
import com.google.mlkit.nl.translate.Translator
|
||||
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class ResultOrError(
|
||||
var result: String?,
|
||||
var sourceLang: String?,
|
||||
var targetLang: String?,
|
||||
var error: Exception?
|
||||
)
|
||||
|
||||
object LanguageTranslatorService {
|
||||
private val languageIdentification = LanguageIdentification.getClient()
|
||||
val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b")
|
||||
|
||||
private val translators =
|
||||
object : LruCache<TranslatorOptions, Translator>(10) {
|
||||
override fun create(options: TranslatorOptions): Translator {
|
||||
return Translation.getClient(options)
|
||||
}
|
||||
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: TranslatorOptions,
|
||||
oldValue: Translator,
|
||||
newValue: Translator?
|
||||
) {
|
||||
oldValue.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun identifyLanguage(text: String): Task<String> {
|
||||
return languageIdentification.identifyLanguage(text)
|
||||
}
|
||||
|
||||
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
|
||||
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
|
||||
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
|
||||
|
||||
if (sourceLangCode == null || targetLangCode == null) {
|
||||
return Tasks.forCanceled()
|
||||
}
|
||||
|
||||
val options = TranslatorOptions.Builder()
|
||||
.setSourceLanguage(sourceLangCode)
|
||||
.setTargetLanguage(targetLangCode)
|
||||
.build()
|
||||
|
||||
val translator = translators[options]
|
||||
|
||||
return translator.downloadModelIfNeeded().onSuccessTask {
|
||||
val tasks = mutableListOf<Task<String>>()
|
||||
|
||||
val dict = lnDictionary(text) + urlDictionary(text)
|
||||
|
||||
for (paragraph in encodeDictionary(text, dict).split("\n")) {
|
||||
tasks.add(translator.translate(paragraph))
|
||||
}
|
||||
|
||||
Tasks.whenAll(tasks).continueWith {
|
||||
val results: MutableList<String> = ArrayList()
|
||||
for (task in tasks) {
|
||||
var fixedText = task.result.replace("# [","#[") // fixes tags that always return with a space
|
||||
results.add(decodeDictionary(fixedText, dict))
|
||||
}
|
||||
ResultOrError(results.joinToString("\n"), source, target, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeDictionary(text: String, dict: Map<String, String>): String {
|
||||
var newText = text
|
||||
for (pair in dict) {
|
||||
newText = newText.replace(pair.value, pair.key, true)
|
||||
}
|
||||
return newText
|
||||
}
|
||||
|
||||
private fun decodeDictionary(text: String, dict: Map<String, String>): String {
|
||||
var newText = text
|
||||
for (pair in dict) {
|
||||
newText = newText.replace(pair.key, pair.value, true)
|
||||
}
|
||||
return newText
|
||||
}
|
||||
|
||||
private fun lnDictionary(text: String): Map<String, String> {
|
||||
val matcher = lnRegex.matcher(text)
|
||||
val returningList = mutableMapOf<String, String>()
|
||||
val counter = 0
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val lnInvoice = matcher.group()
|
||||
val short = "Amethystlnindexer${counter}"
|
||||
returningList.put(short, lnInvoice)
|
||||
} catch (e: Exception) {
|
||||
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
private fun urlDictionary(text: String): Map<String, String> {
|
||||
val parser = UrlDetector(text, UrlDetectorOptions.Default)
|
||||
val urlsInText = parser.detect()
|
||||
|
||||
val counter = 0
|
||||
|
||||
return urlsInText.filter { !it.originalUrl.contains(",") || !it.originalUrl.contains("。") }.associate {
|
||||
"Amethysturlindexer${counter}" to it.originalUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun autoTranslate(text: String, dontTranslateFrom: Set<String>, translateTo: String): Task<ResultOrError> {
|
||||
return identifyLanguage(text).onSuccessTask {
|
||||
if (it == translateTo) {
|
||||
Tasks.forCanceled()
|
||||
} else if (it != "und" && !dontTranslateFrom.contains(it)) {
|
||||
translate(text, it, translateTo)
|
||||
} else {
|
||||
Tasks.forCanceled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import java.net.URLEncoder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class LightningAddressResolver {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
|
||||
}
|
||||
|
||||
if (lnaddress.lowercase().startsWith("lnurl")) {
|
||||
return try {
|
||||
String(Bech32.decodeBytes(lnaddress, false).second)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder().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")
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful)
|
||||
onSuccess(response.body.string())
|
||||
else
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fetchLightningAddressJson(lnaddress,
|
||||
onSuccess = {
|
||||
onSuccess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(lnaddress,
|
||||
onSuccess = {
|
||||
val lnurlp = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
val callback = lnurlp?.get("callback")?.asText()
|
||||
|
||||
if (callback == null) {
|
||||
onError("Callback URL not found in the User's lightning address server configuration")
|
||||
}
|
||||
|
||||
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
|
||||
|
||||
callback?.let { callback ->
|
||||
fetchLightningInvoice(callback, milliSats, message, if (allowsNostr) nostrRequest else null,
|
||||
onSuccess = {
|
||||
val lnInvoice = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.let { pr ->
|
||||
onSuccess(pr)
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.lnurl
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
@@ -129,7 +128,7 @@ object LnInvoiceUtil {
|
||||
}
|
||||
|
||||
private fun multiplier(multiplier: String): BigDecimal {
|
||||
return when (multiplier.toLowerCase()) {
|
||||
return when (multiplier.lowercase()) {
|
||||
"m" -> BigDecimal("0.001")
|
||||
"u" -> BigDecimal("0.000001")
|
||||
"n" -> BigDecimal("0.000000001")
|
||||
@@ -154,4 +153,4 @@ object LnInvoiceUtil {
|
||||
matcher.group()
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.NIP19TLVTypes
|
||||
import com.vitorpamplona.amethyst.service.parseTLV
|
||||
import com.vitorpamplona.amethyst.service.toInt32
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toByteArray
|
||||
|
||||
data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String) {
|
||||
fun toTag() = "$kind:$pubKeyHex:$dTag"
|
||||
|
||||
fun toNAddr(): String {
|
||||
val kind = kind.toByteArray()
|
||||
val addr = pubKeyHex.toByteArray()
|
||||
val dTag = dTag.toByteArray(Charsets.UTF_8)
|
||||
|
||||
val fullArray =
|
||||
byteArrayOf(NIP19TLVTypes.SPECIAL.id, dTag.size.toByte()) + dTag +
|
||||
byteArrayOf(NIP19TLVTypes.AUTHOR.id, addr.size.toByte()) + addr +
|
||||
byteArrayOf(NIP19TLVTypes.KIND.id, kind.size.toByte()) + kind
|
||||
|
||||
return Bech32.encodeBytes(hrp = "naddr", fullArray, Bech32.Encoding.Bech32)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(address: String): ATag? {
|
||||
return if (address.startsWith("naddr") || address.startsWith("nostr:naddr"))
|
||||
parseNAddr(address)
|
||||
else
|
||||
parseAtag(address)
|
||||
}
|
||||
|
||||
fun parseAtag(atag: String): ATag? {
|
||||
return try {
|
||||
val parts = atag.split(":")
|
||||
Hex.decode(parts[1])
|
||||
ATag(parts[0].toInt(), parts[1], parts[2])
|
||||
} catch (t: Throwable) {
|
||||
Log.w("ATag", "Error parsing A Tag: ${atag}: ${t.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun parseNAddr(naddr: String): ATag? {
|
||||
try {
|
||||
val key = naddr.removePrefix("nostr:")
|
||||
|
||||
if (key.startsWith("naddr")) {
|
||||
val tlv = parseTLV(key.bechToBytes())
|
||||
val d = tlv.get(NIP19TLVTypes.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8) ?: ""
|
||||
val relay = tlv.get(NIP19TLVTypes.RELAY.id)?.get(0)?.toString(Charsets.UTF_8)
|
||||
val author = tlv.get(NIP19TLVTypes.AUTHOR.id)?.get(0)?.toHexKey()
|
||||
val kind = tlv.get(NIP19TLVTypes.KIND.id)?.get(0)?.let { toInt32(it) }
|
||||
|
||||
if (kind != null && author != null)
|
||||
return ATag(kind, author, d)
|
||||
}
|
||||
|
||||
} catch (e: Throwable) {
|
||||
Log.w( "ATag", "Issue trying to Decode NIP19 ${this}: ${e.message}")
|
||||
//e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class BadgeAwardEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val kind = 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class BadgeDefinitionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag())
|
||||
|
||||
fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
|
||||
companion object {
|
||||
const val kind = 30009
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
class BadgeProfilesEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag())
|
||||
|
||||
companion object {
|
||||
const val kind = 30008
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,45 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
|
||||
class ChannelCreateEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val channelInfo: ChannelData
|
||||
|
||||
init {
|
||||
try {
|
||||
channelInfo = MetadataEvent.gson.fromJson(content, ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
throw Error("can't parse $content", e)
|
||||
}
|
||||
fun channelInfo() = try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
|
||||
ChannelData(null, null, null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 40
|
||||
|
||||
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
|
||||
val content = if (channelInfo != null)
|
||||
gson.toJson(channelInfo)
|
||||
else
|
||||
val content = try {
|
||||
if (channelInfo != null)
|
||||
gson.toJson(channelInfo)
|
||||
else
|
||||
""
|
||||
} catch (t: Throwable) {
|
||||
Log.e("ChannelCreateEvent", "Couldn't parse channel information", t)
|
||||
""
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = emptyList<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-12
@@ -1,30 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
class ChannelHideMessageEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val eventsToHide: List<String>
|
||||
|
||||
init {
|
||||
eventsToHide = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(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>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
|
||||
val content = reason
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags =
|
||||
messagesToHide?.map {
|
||||
listOf("e", it)
|
||||
@@ -32,7 +28,7 @@ class ChannelHideMessageEvent (
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,29 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
class ChannelMessageEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val channel: String?
|
||||
@Transient val replyTos: List<String>
|
||||
@Transient val mentions: List<String>
|
||||
|
||||
init {
|
||||
channel = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
replyTos = tags.filter { it.getOrNull(1) != channel }.mapNotNull { it.getOrNull(1) }
|
||||
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
}
|
||||
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
|
||||
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 42
|
||||
|
||||
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf(
|
||||
listOf("e", channel, "", "root")
|
||||
)
|
||||
@@ -41,7 +36,7 @@ class ChannelMessageEvent (
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
return ChannelMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-21
@@ -1,46 +1,43 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.ContactMetaData
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
class ChannelMetadataEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val channel: String?
|
||||
@Transient val channelInfo: ChannelCreateEvent.ChannelData
|
||||
|
||||
init {
|
||||
channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun channelInfo() =
|
||||
try {
|
||||
channelInfo = MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
|
||||
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
throw Error("can't parse $content", e)
|
||||
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
|
||||
ChannelCreateEvent.ChannelData(null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 41
|
||||
|
||||
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
|
||||
val content = if (newChannelInfo != null)
|
||||
gson.toJson(newChannelInfo)
|
||||
else
|
||||
""
|
||||
val content =
|
||||
if (newChannelInfo != null)
|
||||
gson.toJson(newChannelInfo)
|
||||
else
|
||||
""
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf( listOf("e", originalChannelIdHex, "", "root") )
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
class ChannelMuteUserEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val usersToMute: List<String>
|
||||
|
||||
init {
|
||||
usersToMute = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(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 {
|
||||
val content = reason
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags =
|
||||
usersToMute?.map {
|
||||
listOf("p", it)
|
||||
@@ -33,7 +29,7 @@ class ChannelMuteUserEvent (
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
return ChannelMuteUserEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
data class Contact(val pubKeyHex: String, val relayUri: String?)
|
||||
|
||||
class ContactListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun follows() = try {
|
||||
tags.filter { it[0] == "p" }.map { Contact(it[1], it.getOrNull(2)) }
|
||||
} catch (e: Exception) {
|
||||
Log.e("ContactListEvent", "can't parse tags as follows: $tags", e)
|
||||
null
|
||||
}
|
||||
|
||||
fun relayUse() = try {
|
||||
if (content.isNotEmpty())
|
||||
gson.fromJson(content, object: TypeToken<Map<String, ReadWrite>>() {}.type)
|
||||
else
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e("ContactListEvent", "can't parse content as relay lists: $tags", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 3
|
||||
|
||||
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
|
||||
val content = if (relayUse != null)
|
||||
gson.toJson(relayUse)
|
||||
else
|
||||
""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = follows.map {
|
||||
if (it.relayUri != null)
|
||||
listOf("p", it.pubKeyHex, it.relayUri)
|
||||
else
|
||||
listOf("p", it.pubKeyHex)
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
data class ReadWrite(val read: Boolean, val write: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class DeletionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun deleteEvents() = tags.map { it[1] }
|
||||
|
||||
companion object {
|
||||
const val kind = 5
|
||||
|
||||
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): DeletionEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = deleteEvents.map { listOf("e", it) }
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return DeletionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import fr.acinq.secp256k1.Secp256k1
|
||||
import java.lang.reflect.Type
|
||||
import java.security.MessageDigest
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
|
||||
open class Event(
|
||||
val id: HexKey,
|
||||
@SerializedName("pubkey") val pubKey: HexKey,
|
||||
@SerializedName("created_at") val createdAt: Long,
|
||||
val kind: Int,
|
||||
val tags: List<List<String>>,
|
||||
val content: String,
|
||||
val sig: HexKey
|
||||
) {
|
||||
fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun generateId(): String {
|
||||
val rawEvent = listOf(
|
||||
0,
|
||||
pubKey,
|
||||
createdAt,
|
||||
kind,
|
||||
tags,
|
||||
content
|
||||
)
|
||||
|
||||
// GSON decided to hardcode these replacements.
|
||||
// They break Nostr's hash check.
|
||||
// These lines revert their code.
|
||||
// https://github.com/google/gson/issues/2295
|
||||
val rawEventJson = gson.toJson(rawEvent)
|
||||
.replace("\\u2028", "\u2028")
|
||||
.replace("\\u2029", "\u2029")
|
||||
|
||||
return sha256.digest(rawEventJson.toByteArray()).toHexKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||
*/
|
||||
fun checkSignature() {
|
||||
if (!id.contentEquals(generateId())) {
|
||||
throw Exception(
|
||||
"""|Unexpected ID.
|
||||
| Event: ${toJson()}
|
||||
| Actual ID: ${id}
|
||||
| Generated: ${generateId()}""".trimIndent()
|
||||
)
|
||||
}
|
||||
if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
|
||||
throw Exception("""Bad signature!""")
|
||||
}
|
||||
}
|
||||
|
||||
fun hasValidSignature(): Boolean {
|
||||
if (!id.contentEquals(generateId())) {
|
||||
return false
|
||||
}
|
||||
if (!Secp256k1.get().verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
class EventDeserializer : JsonDeserializer<Event> {
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type?,
|
||||
context: JsonDeserializationContext?
|
||||
): Event {
|
||||
val jsonObject = json.asJsonObject
|
||||
return Event(
|
||||
id = jsonObject.get("id").asString,
|
||||
pubKey = jsonObject.get("pubkey").asString,
|
||||
createdAt = jsonObject.get("created_at").asLong,
|
||||
kind = jsonObject.get("kind").asInt,
|
||||
tags = jsonObject.get("tags").asJsonArray.map {
|
||||
it.asJsonArray.map { s -> s.asString }
|
||||
},
|
||||
content = jsonObject.get("content").asString,
|
||||
sig = jsonObject.get("sig").asString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class EventSerializer : JsonSerializer<Event> {
|
||||
override fun serialize(
|
||||
src: Event,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
addProperty("id", src.id)
|
||||
addProperty("pubkey", src.pubKey)
|
||||
addProperty("created_at", src.createdAt)
|
||||
addProperty("kind", src.kind)
|
||||
add("tags", JsonArray().also { jsonTags ->
|
||||
src.tags.forEach { tag ->
|
||||
jsonTags.add(JsonArray().also { jsonTagElement ->
|
||||
tag.forEach { tagElement ->
|
||||
jsonTagElement.add(tagElement)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
addProperty("content", src.content)
|
||||
addProperty("sig", src.sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type?,
|
||||
context: JsonDeserializationContext?
|
||||
): ByteArray = Hex.decode(json.asString)
|
||||
}
|
||||
|
||||
class ByteArraySerializer : JsonSerializer<ByteArray> {
|
||||
override fun serialize(
|
||||
src: ByteArray,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
) = JsonPrimitive(src.toHex())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val secp256k1 = Secp256k1.get()
|
||||
|
||||
val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(Event::class.java, EventSerializer())
|
||||
.registerTypeAdapter(Event::class.java, EventDeserializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
|
||||
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
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)
|
||||
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
LnZapEvent.kind -> LnZapEvent(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)
|
||||
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
|
||||
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
else -> this
|
||||
}
|
||||
|
||||
fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: List<List<String>>, content: String): ByteArray {
|
||||
val rawEvent = listOf(
|
||||
0,
|
||||
pubKey,
|
||||
createdAt,
|
||||
kind,
|
||||
tags,
|
||||
content
|
||||
)
|
||||
val rawEventJson = gson.toJson(rawEvent)
|
||||
return sha256.digest(rawEventJson.toByteArray())
|
||||
}
|
||||
|
||||
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey).toHexKey()
|
||||
return Event(id.toHexKey(), pubKey, createdAt, kind, tags, content, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
|
||||
class LnZapEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
fun lnInvoice() = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun preimage() = tags.filter { it.firstOrNull() == "preimage" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
|
||||
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
|
||||
// Keeps this as a field because it's a heavier function used everywhere.
|
||||
val amount = lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
|
||||
|
||||
fun containedPost() = try {
|
||||
description()?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 9735
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
|
||||
class LnZapRequestEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
fun create(originalNote: Event, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("e", originalNote.id),
|
||||
listOf("p", originalNote.pubKey),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
tags = tags + listOf( listOf("a", originalNote.address().toTag()) )
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("p", userHex),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
{
|
||||
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
|
||||
"content": "",
|
||||
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
|
||||
"created_at": 1674164539,
|
||||
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
|
||||
"kind": 9734,
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
|
||||
],
|
||||
[
|
||||
"relays",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nostr-relay.wlvs.space",
|
||||
"wss://nostr.fmt.wiz.biz",
|
||||
"wss://relay.nostr.bg",
|
||||
"wss://nostr.oxtr.dev",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://brb.io",
|
||||
"wss://nostr.bitcoiner.social",
|
||||
"ws://monad.jb55.com:8080",
|
||||
"wss://relay.snort.social"
|
||||
]
|
||||
]
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class LongTextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag())
|
||||
|
||||
fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
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()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 30023
|
||||
|
||||
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LongTextNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LongTextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
data class ContactMetaData(
|
||||
val name: String,
|
||||
val picture: String,
|
||||
val about: String,
|
||||
val nip05: String?)
|
||||
|
||||
class MetadataEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun contactMetaData() = try {
|
||||
gson.fromJson(content, ContactMetaData::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MetadataEvent", "Can't parse $content", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 0
|
||||
val gson = Gson()
|
||||
|
||||
fun create(contactMetaData: ContactMetaData, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
|
||||
return create(gson.toJson(contactMetaData), privateKey, createdAt = createdAt)
|
||||
}
|
||||
|
||||
fun create(contactMetaData: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
|
||||
val content = contactMetaData
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
|
||||
class PrivateDmEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
/**
|
||||
* This may or may not be the actual recipient's pub key. The event is intended to look like a
|
||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||
* for initial messages.
|
||||
*/
|
||||
fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
|
||||
|
||||
/**
|
||||
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
||||
*
|
||||
* Nip-18 messages should refer to other events by inline references in the content like
|
||||
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
|
||||
*/
|
||||
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
|
||||
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
|
||||
|
||||
val retVal = Utils.decrypt(content, sharedSecret)
|
||||
|
||||
if (retVal.startsWith(nip18Advertisement)) {
|
||||
retVal.substring(16)
|
||||
} else {
|
||||
retVal
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val kind = 4
|
||||
|
||||
const val nip18Advertisement = "[//]: # (nip18)\n"
|
||||
|
||||
fun create(
|
||||
recipientPubKey: ByteArray,
|
||||
msg: String,
|
||||
replyTos: List<String>? = null, mentions: List<String>? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
publishedRecipientPubKey: ByteArray? = null,
|
||||
advertiseNip18: Boolean = true
|
||||
): PrivateDmEvent {
|
||||
val content = Utils.encrypt(
|
||||
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
|
||||
privateKey,
|
||||
recipientPubKey)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
publishedRecipientPubKey?.let {
|
||||
tags.add(listOf("p", publishedRecipientPubKey.toHex()))
|
||||
}
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
class ReactionEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
@Transient val originalPost: List<String>
|
||||
@Transient val originalAuthor: List<String>
|
||||
|
||||
init {
|
||||
originalPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
}
|
||||
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val kind = 7
|
||||
@@ -34,11 +31,16 @@ class ReactionEvent (
|
||||
}
|
||||
|
||||
fun create(content: String, originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex()))
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
var tags = listOf( listOf("e", originalNote.id), listOf("p", originalNote.pubKey))
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
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, pubKey, createdAt, tags, content, sig)
|
||||
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.net.URI
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class RecommendRelayEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
val lenient: Boolean = false
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun relay() = if (lenient)
|
||||
URI.create(content.trim())
|
||||
else
|
||||
URI.create(content)
|
||||
|
||||
|
||||
companion object {
|
||||
const val kind = 2
|
||||
|
||||
fun create(relay: URI, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RecommendRelayEvent {
|
||||
val content = relay.toString()
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RecommendRelayEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,95 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.ui.text.toUpperCase
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
|
||||
|
||||
// NIP 56 event.
|
||||
class ReportEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
@Transient val reportType: List<ReportType>
|
||||
@Transient val reportedPost: List<String>
|
||||
@Transient val reportedAuthor: List<String>
|
||||
|
||||
init {
|
||||
reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }
|
||||
reportedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
reportedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
private fun defaultReportType(): ReportType {
|
||||
// Works with old and new structures for report.
|
||||
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
|
||||
if (reportType == null) {
|
||||
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
|
||||
}
|
||||
if (reportType == null) {
|
||||
reportType = ReportType.SPAM
|
||||
}
|
||||
return reportType
|
||||
}
|
||||
|
||||
fun reportedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType()
|
||||
)
|
||||
}
|
||||
|
||||
fun reportedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType()
|
||||
)
|
||||
}
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val kind = 1984
|
||||
|
||||
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportTypeTag = listOf("report", type.name.toLowerCase())
|
||||
val reportPostTag = listOf("e", reportedPost.id.toHex())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex())
|
||||
val reportPostTag = listOf("e", reportedPost.id, type.name.lowercase())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey, type.name.lowercase())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags:List<List<String>> = listOf(reportPostTag, reportAuthorTag)
|
||||
|
||||
if (reportedPost is LongTextNoteEvent) {
|
||||
tags = tags + listOf( listOf("a", reportedPost.address().toTag()) )
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags:List<List<String>> = listOf(reportTypeTag, reportPostTag, reportAuthorTag)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportTypeTag = listOf("report", type.name.toLowerCase())
|
||||
val reportAuthorTag = listOf("p", reportedUser)
|
||||
val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags:List<List<String>> = listOf(reportTypeTag, reportAuthorTag)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags:List<List<String>> = listOf(reportAuthorTag)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
enum class ReportType() {
|
||||
EXPLICIT,
|
||||
EXPLICIT, // Not used anymore.
|
||||
ILLEGAL,
|
||||
SPAM,
|
||||
IMPERSONATION
|
||||
IMPERSONATION,
|
||||
NUDITY,
|
||||
PROFANITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
class RepostEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
@Transient val boostedPost: List<String>
|
||||
@Transient val originalAuthor: List<String>
|
||||
@Transient val containedPost: Event?
|
||||
|
||||
init {
|
||||
boostedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
containedPost = try {
|
||||
fromJson(content, Client.lenient)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
fun containedPost() = try {
|
||||
fromJson(content, Client.lenient)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -36,14 +32,19 @@ class RepostEvent (
|
||||
fun create(boostedPost: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
|
||||
val content = boostedPost.toJson()
|
||||
|
||||
val replyToPost = listOf("e", boostedPost.id.toHex())
|
||||
val replyToAuthor = listOf("p", boostedPost.pubKey.toHex())
|
||||
val replyToPost = listOf("e", boostedPost.id)
|
||||
val replyToAuthor = listOf("p", boostedPost.pubKey)
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags:List<List<String>> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor))
|
||||
|
||||
if (boostedPost is LongTextNoteEvent) {
|
||||
tags = tags + listOf( listOf("a", boostedPost.address().toTag()) )
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags:List<List<String>> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor))
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
return RepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class TextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 1
|
||||
|
||||
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, addresses: List<ATag>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import java.util.UUID
|
||||
import nostr.postr.events.Event
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
|
||||
/**
|
||||
* The Nostr Client manages multiple personae the user may switch between. Events are received and
|
||||
@@ -23,6 +26,7 @@ object Client: RelayPool.Listener {
|
||||
private var relays = Constants.convertDefaultRelays()
|
||||
private var subscriptions = mapOf<String, List<TypedFilter>>()
|
||||
|
||||
@Synchronized
|
||||
fun connect(relays: Array<Relay>) {
|
||||
RelayPool.register(this)
|
||||
RelayPool.unloadRelays()
|
||||
@@ -30,6 +34,18 @@ object Client: RelayPool.Listener {
|
||||
this.relays = relays
|
||||
}
|
||||
|
||||
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>): Boolean {
|
||||
if (relays.size != newRelayConfig.size) return false
|
||||
|
||||
relays.forEach { oldRelayInfo ->
|
||||
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false
|
||||
|
||||
if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendFilter(
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: List<TypedFilter> = listOf()
|
||||
@@ -50,8 +66,9 @@ object Client: RelayPool.Listener {
|
||||
RelayPool.send(signedEvent)
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String){
|
||||
fun close(subscriptionId: String) {
|
||||
RelayPool.close(subscriptionId)
|
||||
subscriptions = subscriptions.minus(subscriptionId)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
@@ -61,19 +78,35 @@ object Client: RelayPool.Listener {
|
||||
}
|
||||
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
|
||||
listeners.forEach { it.onError(error, subscriptionId, relay) }
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onError(error, subscriptionId, relay) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
|
||||
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(listener: Listener) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
val activeTypesSearch = setOf(FeedType.SEARCH)
|
||||
|
||||
fun convertDefaultRelays(): Array<Relay> {
|
||||
return defaultRelays.map {
|
||||
@@ -14,15 +15,15 @@ object Constants {
|
||||
|
||||
val defaultRelays = arrayOf(
|
||||
// Free relays
|
||||
NewRelayListViewModel.Relay("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
|
||||
NewRelayListViewModel.Relay("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
|
||||
NewRelayListViewModel.Relay("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
|
||||
NewRelayListViewModel.Relay("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
|
||||
NewRelayListViewModel.Relay("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
NewRelayListViewModel.Relay("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
|
||||
NewRelayListViewModel.Relay("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
|
||||
NewRelayListViewModel.Relay("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
|
||||
NewRelayListViewModel.Relay("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
|
||||
|
||||
// Less Reliable
|
||||
//NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
@@ -35,14 +36,19 @@ object Constants {
|
||||
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
|
||||
|
||||
// Paid relays
|
||||
NewRelayListViewModel.Relay("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
NewRelayListViewModel.Relay("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
NewRelayListViewModel.Relay("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
NewRelayListViewModel.Relay("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
NewRelayListViewModel.Relay("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
NewRelayListViewModel.Relay("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
NewRelayListViewModel.Relay("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
NewRelayListViewModel.Relay("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
NewRelayListViewModel.Relay("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
)
|
||||
|
||||
val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import java.io.Serializable
|
||||
import java.util.*
|
||||
|
||||
interface Filter {
|
||||
fun match(event: Event): Boolean
|
||||
fun toShortString(): String
|
||||
}
|
||||
|
||||
class JsonFilter(
|
||||
val ids: List<String>? = null,
|
||||
val authors: List<String>? = null,
|
||||
val kinds: List<Int>? = null,
|
||||
val tags: Map<String, List<String>>? = null,
|
||||
val since: Long? = null,
|
||||
val until: Long? = null,
|
||||
val limit: Int? = null,
|
||||
val search: String? = null,
|
||||
) : Filter, Serializable {
|
||||
fun toJson(): String {
|
||||
val jsonObject = JsonObject()
|
||||
ids?.run {
|
||||
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
|
||||
}
|
||||
authors?.run {
|
||||
jsonObject.add("authors", JsonArray().apply { authors.forEach { add(it) } })
|
||||
}
|
||||
kinds?.run {
|
||||
jsonObject.add("kinds", JsonArray().apply { kinds.forEach { add(it) } })
|
||||
}
|
||||
tags?.run {
|
||||
entries.forEach { kv ->
|
||||
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
|
||||
}
|
||||
}
|
||||
since?.run {
|
||||
jsonObject.addProperty("since", since)
|
||||
}
|
||||
until?.run {
|
||||
jsonObject.addProperty("until", until)
|
||||
}
|
||||
limit?.run {
|
||||
jsonObject.addProperty("limit", limit)
|
||||
}
|
||||
search?.run {
|
||||
jsonObject.addProperty("search", search)
|
||||
}
|
||||
return gson.toJson(jsonObject)
|
||||
}
|
||||
|
||||
override fun match(event: Event): Boolean {
|
||||
if (ids?.any { event.id == it } == false) return false
|
||||
if (kinds?.any { event.kind == it } == false) return false
|
||||
if (authors?.any { event.pubKey == it } == false) return false
|
||||
tags?.forEach { tag ->
|
||||
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
|
||||
}
|
||||
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE))
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun toString(): String = "JsonFilter${toJson()}"
|
||||
|
||||
override fun toShortString(): String {
|
||||
val list = ArrayList<String>()
|
||||
ids?.run {
|
||||
list.add("ids")
|
||||
}
|
||||
authors?.run {
|
||||
list.add("authors")
|
||||
}
|
||||
kinds?.run {
|
||||
list.add("kinds[${kinds.joinToString()}]")
|
||||
}
|
||||
tags?.run {
|
||||
list.add("tags")
|
||||
}
|
||||
since?.run {
|
||||
list.add("since")
|
||||
}
|
||||
until?.run {
|
||||
list.add("until")
|
||||
}
|
||||
limit?.run {
|
||||
list.add("limit")
|
||||
}
|
||||
search?.run {
|
||||
list.add("search")
|
||||
}
|
||||
return list.joinToString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder().create()
|
||||
|
||||
fun fromJson(json: String): JsonFilter {
|
||||
val jsonFilter = gson.fromJson(json, JsonObject::class.java)
|
||||
return fromJson(jsonFilter)
|
||||
}
|
||||
|
||||
val declaredFields = JsonFilter::class.java.declaredFields.map { it.name }
|
||||
fun fromJson(json: JsonObject): JsonFilter {
|
||||
// sanity check
|
||||
if (json.keySet().any { !(it.startsWith("#") || it in declaredFields) }) {
|
||||
println("Filter $json contains unknown parameters.")
|
||||
}
|
||||
return JsonFilter(
|
||||
ids = if (json.has("ids")) json.getAsJsonArray("ids").map { it.asString } else null,
|
||||
authors = if (json.has("authors")) json.getAsJsonArray("authors")
|
||||
.map { it.asString } else null,
|
||||
kinds = if (json.has("kinds")) json.getAsJsonArray("kinds")
|
||||
.map { it.asInt } else null,
|
||||
tags = json
|
||||
.entrySet()
|
||||
.filter { it.key.startsWith("#") }
|
||||
.associate {
|
||||
it.key.substring(1) to it.value.asJsonArray.map { it.asString }
|
||||
}
|
||||
.ifEmpty { null },
|
||||
since = if (json.has("since")) json.get("since").asLong else null,
|
||||
until = if (json.has("until")) json.get("until").asLong else null,
|
||||
limit = if (json.has("limit")) json.get("limit").asInt else null,
|
||||
search = if (json.has("search")) json.get("search").asString else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonElement
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
import nostr.postr.events.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -12,7 +11,7 @@ import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
|
||||
enum class FeedType {
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
|
||||
}
|
||||
|
||||
class Relay(
|
||||
@@ -22,9 +21,6 @@ class Relay(
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
|
||||
) {
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(100, TimeUnit.SECONDS)
|
||||
.readTimeout(100, TimeUnit.SECONDS)
|
||||
.callTimeout(100, TimeUnit.SECONDS)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build();
|
||||
@@ -34,6 +30,7 @@ class Relay(
|
||||
private var isReady: Boolean = false
|
||||
|
||||
var eventDownloadCounter = 0
|
||||
var spamCounter = 0
|
||||
var eventUploadCounter = 0
|
||||
var errorCounter = 0
|
||||
var ping: Long? = null
|
||||
@@ -91,7 +88,7 @@ class Relay(
|
||||
"NOTICE" -> listeners.forEach {
|
||||
//Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
// "channel" being the second string in the string array ...
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
//Log.w("Relay", "Relay onOK $url, $channel")
|
||||
@@ -148,6 +145,8 @@ class Relay(
|
||||
|
||||
socket = httpClient.newWebSocket(request, listener)
|
||||
} catch (e: Exception) {
|
||||
errorCounter++
|
||||
isReady = false
|
||||
closingTime = Date().time / 1000
|
||||
Log.e("Relay", "Relay Invalid $url")
|
||||
e.printStackTrace()
|
||||
@@ -175,8 +174,8 @@ class Relay(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// waits 10 seconds to reconnect after disconnected.
|
||||
if (Date().time / 1000 > closingTime + 10) {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (Date().time / 1000 > closingTime + 60) {
|
||||
// sends all filters after connection is successful.
|
||||
requestAndWatch()
|
||||
}
|
||||
@@ -186,8 +185,11 @@ class Relay(
|
||||
|
||||
fun sendFilterOnlyIfDisconnected() {
|
||||
if (socket == null) {
|
||||
//println("sendfilter Only if Disconnected ${url} ")
|
||||
requestAndWatch()
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (Date().time / 1000 > closingTime + 60) {
|
||||
//println("sendfilter Only if Disconnected ${url} ")
|
||||
requestAndWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +204,13 @@ class Relay(
|
||||
socket?.send("""["CLOSE","$subscriptionId"]""")
|
||||
}
|
||||
|
||||
fun isSameRelayConfig(other: Relay): Boolean {
|
||||
return url == other.url
|
||||
&& write == other.write
|
||||
&& read == other.read
|
||||
&& activeTypes == other.activeTypes
|
||||
}
|
||||
|
||||
enum class Type {
|
||||
// Websocket connected
|
||||
CONNECT,
|
||||
|
||||
@@ -5,7 +5,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.events.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
|
||||
/**
|
||||
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
|
||||
@@ -94,7 +94,6 @@ object RelayPool: Relay.Listener {
|
||||
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
|
||||
class TypedFilter(
|
||||
val types: Set<FeedType>,
|
||||
@@ -23,7 +22,7 @@ class TypedFilter(
|
||||
}
|
||||
|
||||
fun typesToJson(types: Set<FeedType>): JsonArray {
|
||||
return JsonArray().apply { types.forEach { add(it.name.toLowerCase()) } }
|
||||
return JsonArray().apply { types.forEach { add(it.name.lowercase()) } }
|
||||
}
|
||||
|
||||
fun filterToJson(filter: JsonFilter): JsonObject {
|
||||
@@ -53,4 +52,4 @@ class TypedFilter(
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.memory.MemoryCache
|
||||
import coil.request.CachePolicy
|
||||
import com.vitorpamplona.amethyst.EncryptedStorage
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.bechToBytes
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -50,7 +41,7 @@ class MainActivity : ComponentActivity() {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
} //.logger(DebugLogger())
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
@@ -60,11 +51,11 @@ class MainActivity : ComponentActivity() {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
|
||||
val accountViewModel: AccountStateViewModel = viewModel {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(LocalPreferences(applicationContext))
|
||||
}
|
||||
|
||||
AccountScreen(accountViewModel, startingPage)
|
||||
AccountScreen(accountStateViewModel, startingPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,7 +65,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
|
||||
// Only starts after login
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
@@ -83,4 +75,14 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
/**
|
||||
* Release memory when the UI becomes hidden or when system resources become low.
|
||||
* @param level the memory-related event that was raised.
|
||||
*/
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
ServiceManager.cleanUp()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
import okhttp3.*
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
import okio.sink
|
||||
import java.io.File
|
||||
|
||||
|
||||
object ImageSaver {
|
||||
/**
|
||||
* Saves the image to the gallery.
|
||||
* May require a storage permission.
|
||||
*
|
||||
* @see PICTURES_SUBDIRECTORY
|
||||
*/
|
||||
fun saveImage(
|
||||
url: String,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val contentType = response.header("Content-Type")
|
||||
checkNotNull(contentType) {
|
||||
"Can't find out the content type"
|
||||
}
|
||||
|
||||
saveContentQ(
|
||||
displayName = File(url).nameWithoutExtension,
|
||||
contentType = contentType,
|
||||
contentSource = response.body.source(),
|
||||
contentResolver = context.contentResolver,
|
||||
)
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = File(url).name,
|
||||
contentSource = response.body.source(),
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveContentQ(
|
||||
displayName: String,
|
||||
contentType: String,
|
||||
contentSource: BufferedSource,
|
||||
contentResolver: ContentResolver,
|
||||
) {
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
|
||||
put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY
|
||||
)
|
||||
}
|
||||
|
||||
val uri =
|
||||
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
|
||||
checkNotNull(uri) {
|
||||
"Can't insert the new content"
|
||||
}
|
||||
|
||||
try {
|
||||
val outputStream = contentResolver.openOutputStream(uri)
|
||||
checkNotNull(outputStream) {
|
||||
"Can't open the content output stream"
|
||||
}
|
||||
|
||||
outputStream.use {
|
||||
contentSource.readAll(it.sink())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
contentResolver.delete(uri, null, null)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveContentDefault(
|
||||
fileName: String,
|
||||
contentSource: BufferedSource,
|
||||
context: Context,
|
||||
) {
|
||||
val subdirectory = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
||||
PICTURES_SUBDIRECTORY
|
||||
)
|
||||
|
||||
if (!subdirectory.exists()) {
|
||||
subdirectory.mkdirs()
|
||||
}
|
||||
|
||||
val outputFile = File(subdirectory, fileName)
|
||||
|
||||
outputFile
|
||||
.outputStream()
|
||||
.use {
|
||||
contentSource.readAll(it.sink())
|
||||
}
|
||||
|
||||
// Call the media scanner manually, so the image
|
||||
// appears in the gallery faster.
|
||||
context.sendBroadcast(
|
||||
Intent(
|
||||
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
|
||||
outputFile.toUri()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private const val PICTURES_SUBDIRECTORY = "Amethyst"
|
||||
}
|
||||
@@ -1,60 +1,75 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
object ImageUploader {
|
||||
private fun encodeImage(bitmap: Bitmap): ByteArray {
|
||||
val byteArrayOutPutStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutPutStream)
|
||||
return byteArrayOutPutStream.toByteArray()
|
||||
}
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (Throwable) -> Unit,
|
||||
) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
|
||||
fun uploadImage(bitmap: Bitmap, onSuccess: (String) -> Unit) {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
val body: RequestBody = MultipartBody.Builder()
|
||||
val requestBody: RequestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"image",
|
||||
"${UUID.randomUUID()}.png",
|
||||
encodeImage(bitmap).toRequestBody("image/png".toMediaType())
|
||||
"${UUID.randomUUID()}",
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType? =
|
||||
contentType?.toMediaType()
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
|
||||
imageInputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.url("https://api.imgur.com/3/image")
|
||||
.header("Authorization", "Client-ID e6aea87296f3f96")
|
||||
.post(body)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
val body = response.body
|
||||
if (body != null) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val tree = jacksonObjectMapper().readTree(body.string())
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
if (url != null)
|
||||
onSuccess(url)
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
|
||||
onSuccess(url)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,18 +17,17 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
|
||||
@@ -71,13 +70,13 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Channel Name") },
|
||||
label = { Text(text = stringResource(R.string.channel_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.channelName.value,
|
||||
onValueChange = { postViewModel.channelName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "My Awesome Group",
|
||||
text = stringResource(R.string.my_awesome_group),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
@@ -90,7 +89,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Picture Url") },
|
||||
label = { Text(text = stringResource(R.string.picture_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.channelPicture.value,
|
||||
onValueChange = { postViewModel.channelPicture.value = it },
|
||||
@@ -105,13 +104,13 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Description") },
|
||||
label = { Text(text = stringResource(R.string.description)) },
|
||||
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||
value = postViewModel.channelDescription.value,
|
||||
onValueChange = { postViewModel.channelDescription.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "About us.. ",
|
||||
text = stringResource(R.string.about_us),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
|
||||
@@ -4,12 +4,8 @@ import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import nostr.postr.toHex
|
||||
|
||||
class NewChannelViewModel: ViewModel() {
|
||||
private var account: Account? = null
|
||||
@@ -38,8 +34,6 @@ class NewChannelViewModel: ViewModel() {
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text
|
||||
)
|
||||
|
||||
LocalPreferences(context).saveToEncryptedStorage(account)
|
||||
} else
|
||||
account.sendChangeChannel(
|
||||
channelName.value.text,
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -37,11 +25,10 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -49,161 +36,172 @@ import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtension
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtension
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import kotlinx.coroutines.delay
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account) {
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
postViewModel.load(account, baseReplyTo)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account, baseReplyTo, quote)
|
||||
delay(100)
|
||||
focusRequester.requestFocus()
|
||||
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
UploadFromGallery {
|
||||
postViewModel.upload(it, context)
|
||||
}
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
postViewModel.message.text.isNotBlank()
|
||||
)
|
||||
}
|
||||
|
||||
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
|
||||
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, "✖ ") {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.message,
|
||||
onValueChange = {
|
||||
postViewModel.updateMessage(it)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colors.surface,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "What's on your mind?",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
|
||||
val userSuggestions = postViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
)
|
||||
Surface(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
|
||||
Column(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
|
||||
Column(modifier = Modifier.padding(10.dp).imePadding().weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { index, item ->
|
||||
UserLine(item, account) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
) {
|
||||
postViewModel.upload(it, context)
|
||||
}
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = postViewModel.message.text.isNotBlank()
|
||||
&& !postViewModel.isUploadingImage
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().verticalScroll(scroolState)) {
|
||||
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
|
||||
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, account, "✖ ") {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.message,
|
||||
onValueChange = {
|
||||
postViewModel.updateMessage(it)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colors.surface,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.what_s_on_your_mind),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl =
|
||||
myUrlPreview.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl)
|
||||
.matches()
|
||||
) {
|
||||
VideoView(myUrlPreview)
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Column(modifier = Modifier.padding(top = 5.dp)) {
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
VideoView(myUrlPreview)
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview)
|
||||
}
|
||||
|
||||
val userSuggestions = postViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }) { index, item ->
|
||||
UserLine(item, account) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,7 +222,7 @@ fun CloseButton(onCancel: () -> Unit) {
|
||||
backgroundColor = Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = "Cancel", color = Color.White)
|
||||
Text(text = stringResource(R.string.cancel), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +241,26 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = "Post", color = Color.White)
|
||||
Text(text = stringResource(R.string.post), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SaveButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onPost()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.save), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +279,7 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = "Create", color = Color.White)
|
||||
Text(text = stringResource(R.string.create), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import nostr.postr.toNpub
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NewPostViewModel: ViewModel() {
|
||||
private var account: Account? = null
|
||||
@@ -30,11 +24,13 @@ class NewPostViewModel: ViewModel() {
|
||||
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError = MutableSharedFlow<String?>()
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
|
||||
fun load(account: Account, replyingTo: Note?) {
|
||||
fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
@@ -45,85 +41,104 @@ class NewPostViewModel: ViewModel() {
|
||||
} else {
|
||||
this.mentions = currentMentions.plus(replyUser)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
|
||||
}
|
||||
|
||||
this.account = account
|
||||
}
|
||||
|
||||
fun addUserToMentionsIfNotInAndReturnIndex(user: User): Int {
|
||||
val replyToSize = replyTos?.size ?: 0
|
||||
fun addUserToMentions(user: User) {
|
||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||
}
|
||||
|
||||
var myMentions = mentions
|
||||
if (myMentions == null) {
|
||||
mentions = listOf(user)
|
||||
return replyToSize + 0 // position of the user
|
||||
}
|
||||
fun addNoteToReplyTos(note: Note) {
|
||||
note.author?.let { addUserToMentions(it) }
|
||||
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
|
||||
}
|
||||
|
||||
val index = myMentions.indexOf(user)
|
||||
fun tagIndex(user: User): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||
}
|
||||
|
||||
if (index >= 0) return replyToSize + index
|
||||
|
||||
myMentions = myMentions.plus(user)
|
||||
mentions = myMentions
|
||||
return replyToSize + myMentions.indexOf(user)
|
||||
fun tagIndex(note: Note): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
// Moves @npub to mentions
|
||||
// adds all references to mentions and reply tos
|
||||
message.text.split('\n').forEach { paragraph: String ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
|
||||
if (results?.type == "npub") {
|
||||
addUserToMentions(LocalCache.getOrCreateUser(results.keyHex))
|
||||
} else if (results?.type == "note") {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.keyHex))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags the text in the correct order.
|
||||
val newMessage = message.text.split('\n').map { paragraph: String ->
|
||||
paragraph.split(' ').map { word: String ->
|
||||
try {
|
||||
if (word.startsWith("@npub") && word.length >= 64) {
|
||||
val keyB32 = word.substring(0, 64)
|
||||
val restOfWord = word.substring(64)
|
||||
val results = parseDirtyWordForKey(word)
|
||||
if (results?.type == "npub") {
|
||||
val user = LocalCache.getOrCreateUser(results.keyHex)
|
||||
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key.toHexKey())
|
||||
"#[${tagIndex(user)}]${results.restOfWord}"
|
||||
} else if (results?.type == "note") {
|
||||
val note = LocalCache.getOrCreateNote(results.keyHex)
|
||||
|
||||
val index = addUserToMentionsIfNotInAndReturnIndex(user)
|
||||
|
||||
val newWord = "#[${index}]"
|
||||
|
||||
newWord + restOfWord
|
||||
} else {
|
||||
word
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// if it can't parse the key, don't try to change.
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
} else {
|
||||
word
|
||||
}
|
||||
}.joinToString(" ")
|
||||
}.joinToString("\n")
|
||||
|
||||
if (originalNote?.channel != null) {
|
||||
account?.sendChannelMeesage(newMessage, originalNote!!.channel!!.idHex, originalNote!!, mentions)
|
||||
if (originalNote?.channel() != null) {
|
||||
account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions)
|
||||
} else {
|
||||
account?.sendPost(newMessage, replyTos, mentions)
|
||||
}
|
||||
|
||||
message = TextFieldValue("")
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
mentions = null
|
||||
}
|
||||
|
||||
fun upload(it: Uri, context: Context) {
|
||||
val img = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(context.contentResolver, it)
|
||||
} else {
|
||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, it))
|
||||
}
|
||||
isUploadingImage = true
|
||||
|
||||
img?.let {
|
||||
ImageUploader.uploadImage(img) {
|
||||
message = TextFieldValue(message.text + "\n\n" + it)
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
contentResolver = context.contentResolver,
|
||||
onSuccess = { imageUrl ->
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
mentions = null
|
||||
}
|
||||
|
||||
fun findUrlInMessage(): String? {
|
||||
@@ -157,7 +172,7 @@ class NewPostViewModel: ViewModel() {
|
||||
userSuggestionAnchor?.let {
|
||||
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkey.toNpub()} "
|
||||
val wordToInsert = "@${item.pubkeyNpub()} "
|
||||
|
||||
message = TextFieldValue(
|
||||
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.Colors
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
@@ -28,18 +22,14 @@ import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SyncProblem
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material.icons.outlined.BarChart
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -48,12 +38,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -62,6 +51,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import java.lang.Math.round
|
||||
|
||||
@@ -128,6 +118,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
|
||||
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
|
||||
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
|
||||
onToggleGlobal = { postViewModel.toggleGlobal(it) },
|
||||
onToggleSearch = { postViewModel.toggleSearch(it) },
|
||||
|
||||
onDelete = { postViewModel.deleteRelay(it) }
|
||||
)
|
||||
@@ -153,7 +144,7 @@ fun ServerConfigHeader() {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Relay Address",
|
||||
text = stringResource(R.string.relay_address),
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -161,12 +152,12 @@ fun ServerConfigHeader() {
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Column(Modifier.weight(1.4f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.size(25.dp))
|
||||
|
||||
Text(
|
||||
text = "Posts",
|
||||
text = stringResource(R.string.posts),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -176,7 +167,7 @@ fun ServerConfigHeader() {
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
text = "Posts",
|
||||
text = stringResource(id = R.string.posts),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -186,7 +177,7 @@ fun ServerConfigHeader() {
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
text = "Errors",
|
||||
text = stringResource(R.string.errors),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -194,6 +185,16 @@ fun ServerConfigHeader() {
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
|
||||
Text(
|
||||
text = "Spam",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,16 +207,17 @@ fun ServerConfigHeader() {
|
||||
|
||||
@Composable
|
||||
fun ServerConfig(
|
||||
item: NewRelayListViewModel.Relay,
|
||||
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
|
||||
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
|
||||
item: RelaySetupInfo,
|
||||
onToggleDownload: (RelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (RelaySetupInfo) -> Unit,
|
||||
|
||||
onToggleFollows: (NewRelayListViewModel.Relay) -> Unit,
|
||||
onTogglePrivateDMs: (NewRelayListViewModel.Relay) -> Unit,
|
||||
onTogglePublicChats: (NewRelayListViewModel.Relay) -> Unit,
|
||||
onToggleGlobal: (NewRelayListViewModel.Relay) -> Unit,
|
||||
onToggleFollows: (RelaySetupInfo) -> Unit,
|
||||
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
|
||||
onTogglePublicChats: (RelaySetupInfo) -> Unit,
|
||||
onToggleGlobal: (RelaySetupInfo) -> Unit,
|
||||
onToggleSearch: (RelaySetupInfo) -> Unit,
|
||||
|
||||
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
|
||||
onDelete: (RelaySetupInfo) -> Unit) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -229,7 +231,9 @@ fun ServerConfig(
|
||||
Icon(
|
||||
imageVector = Icons.Default.Cancel,
|
||||
null,
|
||||
modifier = Modifier.padding(end = 5.dp).size(15.dp),
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = Color.Red
|
||||
)
|
||||
}
|
||||
@@ -254,8 +258,10 @@ fun ServerConfig(
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_home),
|
||||
"Home Feed",
|
||||
modifier = Modifier.padding(end = 5.dp).size(15.dp),
|
||||
stringResource(R.string.home_feed),
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
@@ -267,8 +273,10 @@ fun ServerConfig(
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_dm),
|
||||
"Private Message Feed",
|
||||
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
|
||||
stringResource(R.string.private_message_feed),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
@@ -280,8 +288,10 @@ fun ServerConfig(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Groups,
|
||||
"Public Chat Feed",
|
||||
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
|
||||
stringResource(R.string.public_chat_feed),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
@@ -293,17 +303,35 @@ fun ServerConfig(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Public,
|
||||
"Global Feed",
|
||||
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
|
||||
stringResource(R.string.global_feed),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = { onToggleSearch(item) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
stringResource(R.string.search_feed),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.SEARCH)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Column(Modifier.weight(1.4f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
@@ -312,7 +340,9 @@ fun ServerConfig(
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
null,
|
||||
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.read) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
@@ -334,7 +364,9 @@ fun ServerConfig(
|
||||
Icon(
|
||||
imageVector = Icons.Default.Upload,
|
||||
null,
|
||||
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.write) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
@@ -352,7 +384,9 @@ fun ServerConfig(
|
||||
Icon(
|
||||
imageVector = Icons.Default.SyncProblem,
|
||||
null,
|
||||
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
|
||||
)
|
||||
|
||||
@@ -363,6 +397,21 @@ fun ServerConfig(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeleteSweep,
|
||||
null,
|
||||
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
|
||||
tint = if (item.spamCount > 0) Color.Yellow else Color.Green
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.spamCount)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,14 +425,14 @@ fun ServerConfig(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
|
||||
fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Unit) {
|
||||
var url by remember { mutableStateOf<String>(relayToAdd) }
|
||||
var read by remember { mutableStateOf(true) }
|
||||
var write by remember { mutableStateOf(true) }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Add a Relay") },
|
||||
label = { Text(text = stringResource(R.string.add_a_relay)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
@@ -422,9 +471,9 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
|
||||
Button(
|
||||
onClick = {
|
||||
if (url.isNotBlank() && url != "/") {
|
||||
var addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
|
||||
var addedWSS = if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url
|
||||
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
|
||||
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
|
||||
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
|
||||
url = ""
|
||||
write = true
|
||||
read = true
|
||||
@@ -436,7 +485,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
|
||||
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
) {
|
||||
Text(text = "Add", color = Color.White)
|
||||
Text(text = stringResource(id = R.string.add), color = Color.White)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import nostr.postr.events.ContactListEvent
|
||||
|
||||
class NewRelayListViewModel: ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
data class Relay(
|
||||
val url: String,
|
||||
val read: Boolean,
|
||||
val write: Boolean,
|
||||
val errorCount: Int = 0,
|
||||
val downloadCount: Int = 0,
|
||||
val uploadCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>
|
||||
)
|
||||
|
||||
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
|
||||
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
|
||||
val relays = _relays.asStateFlow()
|
||||
|
||||
fun load(account: Account, ctx: Context) {
|
||||
@@ -37,7 +27,6 @@ class NewRelayListViewModel: ViewModel() {
|
||||
fun create(ctx: Context) {
|
||||
relays.let {
|
||||
account.saveRelayList(it.value)
|
||||
LocalPreferences(ctx).saveToEncryptedStorage(account)
|
||||
}
|
||||
|
||||
clear(ctx)
|
||||
@@ -45,7 +34,15 @@ class NewRelayListViewModel: ViewModel() {
|
||||
|
||||
fun clear(ctx: Context) {
|
||||
_relays.update {
|
||||
val relayFile = account.userProfile().relays
|
||||
var relayFile = account.userProfile().relays
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
// TODO: Remove when search becomes more available.
|
||||
if (relayFile?.none { it.key == Constants.forcedRelayForSearch.url } == true) {
|
||||
relayFile = relayFile + Pair(
|
||||
Constants.forcedRelayForSearch.url, ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write)
|
||||
)
|
||||
}
|
||||
|
||||
if (relayFile != null)
|
||||
relayFile.map {
|
||||
@@ -55,8 +52,9 @@ class NewRelayListViewModel: ViewModel() {
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
|
||||
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
else
|
||||
account.localRelays.map {
|
||||
@@ -65,13 +63,14 @@ class NewRelayListViewModel: ViewModel() {
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
|
||||
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: Relay) {
|
||||
fun addRelay(relay: RelaySetupInfo) {
|
||||
if (relays.value.any { it.url == relay.url }) return
|
||||
|
||||
_relays.update {
|
||||
@@ -79,51 +78,58 @@ class NewRelayListViewModel: ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRelay(relay: Relay) {
|
||||
fun deleteRelay(relay: RelaySetupInfo) {
|
||||
_relays.update {
|
||||
it.minus(relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDownload(relay: Relay) {
|
||||
fun toggleDownload(relay: RelaySetupInfo) {
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy(read = !relay.read))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleUpload(relay: Relay) {
|
||||
fun toggleUpload(relay: RelaySetupInfo) {
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy(write = !relay.write))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFollows(relay: Relay) {
|
||||
fun toggleFollows(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy(feedTypes = newTypes))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleMessages(relay: Relay) {
|
||||
fun toggleMessages(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy(feedTypes = newTypes))
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePublicChats(relay: Relay) {
|
||||
fun togglePublicChats(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy(feedTypes = newTypes))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleGlobal(relay: Relay) {
|
||||
fun toggleGlobal(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy( feedTypes = newTypes ))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSearch(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH)
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy( feedTypes = newTypes ))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
|
||||
|
||||
@@ -17,15 +17,14 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@@ -64,7 +63,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
postViewModel.userName.value.isNotBlank()
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,13 +71,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Display Name") },
|
||||
label = { Text(text = stringResource(R.string.display_name)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.displayName.value,
|
||||
onValueChange = { postViewModel.displayName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "My display name",
|
||||
text = stringResource(R.string.my_display_name),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
@@ -91,13 +90,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
Text("@", Modifier.padding(5.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Username") },
|
||||
label = { Text(text = stringResource(R.string.username)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.userName.value,
|
||||
onValueChange = { postViewModel.userName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "My username",
|
||||
text = stringResource(R.string.my_username),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
@@ -108,7 +107,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "About me") },
|
||||
label = { Text(text = stringResource(R.string.about_me)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
@@ -116,7 +115,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
onValueChange = { postViewModel.about.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "About me",
|
||||
text = stringResource(id = R.string.about_me),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
@@ -129,7 +128,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Avatar URL") },
|
||||
label = { Text(text = stringResource(R.string.avatar_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.picture.value,
|
||||
onValueChange = { postViewModel.picture.value = it },
|
||||
@@ -145,7 +144,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Banner URL") },
|
||||
label = { Text(text = stringResource(R.string.banner_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.banner.value,
|
||||
onValueChange = { postViewModel.banner.value = it },
|
||||
@@ -161,7 +160,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Website URL") },
|
||||
label = { Text(text = stringResource(R.string.website_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.website.value,
|
||||
onValueChange = { postViewModel.website.value = it },
|
||||
@@ -177,7 +176,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "NIP-05") },
|
||||
label = { Text(text = stringResource(R.string.nip_05)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.nip05.value,
|
||||
onValueChange = { postViewModel.nip05.value = it },
|
||||
@@ -192,7 +191,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "LN Address") },
|
||||
label = { Text(text = stringResource(R.string.ln_address)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.lnAddress.value,
|
||||
onValueChange = { postViewModel.lnAddress.value = it },
|
||||
@@ -205,6 +204,22 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.ln_url_outdated)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.lnURL.value,
|
||||
onValueChange = { postViewModel.lnURL.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.lnurl),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-17
@@ -21,6 +21,7 @@ class NewUserMetadataViewModel: ViewModel() {
|
||||
val website = mutableStateOf("")
|
||||
val nip05 = mutableStateOf("")
|
||||
val lnAddress = mutableStateOf("")
|
||||
val lnURL = mutableStateOf("")
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
@@ -28,18 +29,19 @@ class NewUserMetadataViewModel: ViewModel() {
|
||||
account.userProfile().let {
|
||||
userName.value = it.bestUsername() ?: ""
|
||||
displayName.value = it.bestDisplayName() ?: ""
|
||||
about.value = it.info.about ?: ""
|
||||
picture.value = it.info.picture ?: ""
|
||||
banner.value = it.info.banner ?: ""
|
||||
website.value = it.info.website ?: ""
|
||||
nip05.value = it.info.nip05 ?: ""
|
||||
lnAddress.value = it.info.lud16 ?: ""
|
||||
about.value = it.info?.about ?: ""
|
||||
picture.value = it.info?.picture ?: ""
|
||||
banner.value = it.info?.banner ?: ""
|
||||
website.value = it.info?.website ?: ""
|
||||
nip05.value = it.info?.nip05 ?: ""
|
||||
lnAddress.value = it.info?.lud16 ?: ""
|
||||
lnURL.value = it.info?.lud06 ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
fun create() {
|
||||
// Tries to not delete any existing attribute that we do not work with.
|
||||
val latest = account.userProfile().latestMetadata
|
||||
val latest = account.userProfile().info?.latestMetadata
|
||||
val currentJson = if (latest != null) {
|
||||
ObjectMapper().readTree(
|
||||
ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8))
|
||||
@@ -47,16 +49,17 @@ class NewUserMetadataViewModel: ViewModel() {
|
||||
} else {
|
||||
ObjectMapper().createObjectNode()
|
||||
}
|
||||
currentJson.put("name", userName.value)
|
||||
currentJson.put("username", userName.value)
|
||||
currentJson.put("display_name", displayName.value)
|
||||
currentJson.put("displayName", displayName.value)
|
||||
currentJson.put("picture", picture.value)
|
||||
currentJson.put("banner", banner.value)
|
||||
currentJson.put("website", website.value)
|
||||
currentJson.put("about", about.value)
|
||||
currentJson.put("nip05", nip05.value)
|
||||
currentJson.put("lud16", lnAddress.value)
|
||||
currentJson.put("name", userName.value.trim())
|
||||
currentJson.put("username", userName.value.trim())
|
||||
currentJson.put("display_name", displayName.value.trim())
|
||||
currentJson.put("displayName", displayName.value.trim())
|
||||
currentJson.put("picture", picture.value.trim())
|
||||
currentJson.put("banner", banner.value.trim())
|
||||
currentJson.put("website", website.value.trim())
|
||||
currentJson.put("about", about.value.trim())
|
||||
currentJson.put("nip05", nip05.value.trim())
|
||||
currentJson.put("lud16", lnAddress.value.trim())
|
||||
currentJson.put("lud06", lnURL.value.trim())
|
||||
|
||||
val writer = StringWriter()
|
||||
ObjectMapper().writeValue(writer, currentJson)
|
||||
@@ -75,5 +78,6 @@ class NewUserMetadataViewModel: ViewModel() {
|
||||
website.value = ""
|
||||
nip05.value = ""
|
||||
lnAddress.value = ""
|
||||
lnURL.value = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A button to save the remote image to the gallery.
|
||||
* May require a storage permission.
|
||||
*
|
||||
* @param url URL of the image
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(url: String) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
url = url,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
localContext,
|
||||
localContext.getString(R.string.image_saved_to_the_gallery),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
localContext,
|
||||
localContext.getString(R.string.failed_to_save_the_image),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val writeStoragePermissionState = rememberPermissionState(
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
|
||||
saveImage()
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.save), color = Color.White)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -8,25 +9,30 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(
|
||||
android.Manifest.permission.READ_MEDIA_IMAGES
|
||||
)
|
||||
fun UploadFromGallery(
|
||||
isUploading: Boolean,
|
||||
onImageChosen: (Uri) -> Unit,
|
||||
) {
|
||||
val cameraPermissionState =
|
||||
rememberPermissionState(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
android.Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
)
|
||||
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
var showGallerySelect by remember { mutableStateOf(false) }
|
||||
@@ -44,18 +50,30 @@ fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(4.dp),
|
||||
enabled = !isUploading,
|
||||
onClick = {
|
||||
showGallerySelect = true
|
||||
}
|
||||
) {
|
||||
Text("Upload Image")
|
||||
if (!isUploading) {
|
||||
Text(stringResource(R.string.upload_image))
|
||||
} else {
|
||||
Text(stringResource(R.string.uploading))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column {
|
||||
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
|
||||
Text("Upload Image")
|
||||
Button(
|
||||
onClick = { cameraPermissionState.launchPermissionRequest() },
|
||||
enabled = !isUploading,
|
||||
) {
|
||||
if (!isUploading) {
|
||||
Text(stringResource(R.string.upload_image))
|
||||
} else {
|
||||
Text(stringResource(R.string.uploading))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -18,14 +16,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewChannelButton(account: Account) {
|
||||
@@ -45,7 +40,7 @@ fun NewChannelButton(account: Account) {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = "New Channel",
|
||||
contentDescription = stringResource(R.string.new_channel),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.DefaultAlpha
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.LocalImageLoader
|
||||
import java.util.Base64
|
||||
|
||||
data class ResizeImage(val url: String?, val size: Dp) {
|
||||
fun proxyUrl(): String? {
|
||||
if (url == null) return null
|
||||
|
||||
// Fixes Image size to reduce pings to servers for each size used in the app
|
||||
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
|
||||
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
|
||||
|
||||
return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun AsyncImageProxy(
|
||||
model: ResizeImage,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: Painter? = null,
|
||||
error: Painter? = null,
|
||||
fallback: Painter? = error,
|
||||
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
|
||||
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
|
||||
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
|
||||
) {
|
||||
if (model.url == null) {
|
||||
AsyncImage(
|
||||
model = model.url,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = placeholder,
|
||||
error = error,
|
||||
fallback = fallback,
|
||||
onLoading = onLoading,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = model.proxyUrl(),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = placeholder,
|
||||
error = error,
|
||||
fallback = fallback,
|
||||
onLoading = onLoading,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -4,12 +4,9 @@ import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.toNote
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
|
||||
@Composable
|
||||
@@ -18,7 +15,7 @@ fun ClickableNoteTag(
|
||||
navController: NavController
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baesNote.id.toNote().toShortenHex()} "),
|
||||
text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "),
|
||||
onClick = { navController.navigate("Note/${baesNote.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@@ -9,7 +8,6 @@ import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -9,13 +9,8 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toNote
|
||||
import com.vitorpamplona.amethyst.service.Nip19
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import nostr.postr.toNpub
|
||||
|
||||
@Composable
|
||||
fun ClickableRoute(
|
||||
@@ -25,7 +20,7 @@ fun ClickableRoute(
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
val userBase = LocalCache.getOrCreateUser(nip19.hex)
|
||||
|
||||
val userState by userBase.liveMetadata.observeAsState()
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val route = "User/${nip19.hex}"
|
||||
@@ -38,24 +33,24 @@ fun ClickableRoute(
|
||||
)
|
||||
} else {
|
||||
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
|
||||
val noteState by noteBase.live.observeAsState()
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote} "),
|
||||
text = AnnotatedString("@${note.idDisplayNote()} "),
|
||||
onClick = { navController.navigate("Channel/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else if (note.channel != null) {
|
||||
} else if (note.channel() != null) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.channel?.toBestDisplayName()} "),
|
||||
onClick = { navController.navigate("Channel/${note.channel?.idHex}") },
|
||||
text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "),
|
||||
onClick = { navController.navigate("Channel/${note.channel()?.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote} "),
|
||||
text = AnnotatedString("@${note.idDisplayNote()} "),
|
||||
onClick = { navController.navigate("Note/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ fun ClickableUserTag(
|
||||
user: User,
|
||||
navController: NavController
|
||||
) {
|
||||
val innerUserState by user.liveMetadata.observeAsState()
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
|
||||
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
|
||||
@Composable
|
||||
fun ExpandableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
val text = if (showFullText) content else content.take(350)
|
||||
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
//CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
RichTextViewer(
|
||||
text,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
//}
|
||||
|
||||
if (content.length > 350 && !showFullText) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
backgroundColor.copy(alpha = 0f),
|
||||
backgroundColor
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = { showFullText = !showFullText },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_more), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,14 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import java.text.NumberFormat
|
||||
|
||||
@Composable
|
||||
fun InvoicePreview(lnInvoice: String) {
|
||||
@@ -67,7 +69,7 @@ fun InvoicePreview(lnInvoice: String) {
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Lightning Invoice",
|
||||
text = stringResource(R.string.lightning_invoice),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
@@ -78,20 +80,25 @@ fun InvoicePreview(lnInvoice: String) {
|
||||
|
||||
amount?.let {
|
||||
Text(
|
||||
text = "${amount.toInt()} sats",
|
||||
text = "${
|
||||
NumberFormat.getInstance().format(amount)
|
||||
} ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning://$lnInvoice"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
|
||||
startActivity(context, intent, null)
|
||||
}
|
||||
},
|
||||
@@ -100,7 +107,7 @@ fun InvoicePreview(lnInvoice: String) {
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = "Pay", color = Color.White, fontSize = 20.sp)
|
||||
Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user