mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
568 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81b70780a6 | ||
|
|
277fc5971e | ||
|
|
850bc15f4f | ||
|
|
6d2db65c12 | ||
|
|
89ce6dbab2 | ||
|
|
80bfbf68b4 | ||
|
|
4464247ddb | ||
|
|
486d43dc1b | ||
|
|
6731ca59cd | ||
|
|
7d620cd89a | ||
|
|
82a2586eab | ||
|
|
5a3f912b73 | ||
|
|
bc54134e65 | ||
|
|
0f080219b2 | ||
|
|
ff54629601 | ||
|
|
8f257a7e2d | ||
|
|
9015089593 | ||
|
|
2b87930283 | ||
|
|
15d94ca9c4 | ||
|
|
44af7f958c | ||
|
|
2b2028853e | ||
|
|
66dcc03351 | ||
|
|
22ba150c4e | ||
|
|
63cc00976f | ||
|
|
cc9d4c237a | ||
|
|
446135ea2a | ||
|
|
a38bbd7168 | ||
|
|
635ed1afc1 | ||
|
|
130ad40a3a | ||
|
|
be5e58111e | ||
|
|
8b012884b1 | ||
|
|
4949a5c459 | ||
|
|
ab330fea96 | ||
|
|
8209c45303 | ||
|
|
206935289e | ||
|
|
571ff0e72b | ||
|
|
940914adf5 | ||
|
|
1496f012a5 | ||
|
|
2a552bea4d | ||
|
|
d1dc06a467 | ||
|
|
f173f3194b | ||
|
|
8ca63f32f4 | ||
|
|
6b3e10ed28 | ||
|
|
1ae785061e | ||
|
|
ddba4ac510 | ||
|
|
0fe6bd6c83 | ||
|
|
925289ae64 | ||
|
|
f6f82a16f5 | ||
|
|
54511dcc69 | ||
|
|
2f457d135f | ||
|
|
362082d608 | ||
|
|
78cfa98456 | ||
|
|
5e37a97d1e | ||
|
|
561189144e | ||
|
|
285a61e38f | ||
|
|
2ca877b3c3 | ||
|
|
f105188738 | ||
|
|
0a11de1892 | ||
|
|
1596a7b559 | ||
|
|
d4a3ee2651 | ||
|
|
c60ad472e8 | ||
|
|
04e4c4692f | ||
|
|
ab747f5e93 | ||
|
|
e370e75ba4 | ||
|
|
a669e37774 | ||
|
|
86fe9b4a65 | ||
|
|
c6a0b0950a | ||
|
|
f03cd04566 | ||
|
|
3175d40482 | ||
|
|
24d722cdb7 | ||
|
|
14bc1fb7d4 | ||
|
|
90955515d0 | ||
|
|
a22ec35a26 | ||
|
|
f38f5737b0 | ||
|
|
e958515dad | ||
|
|
add6b06446 | ||
|
|
d070712f2b | ||
|
|
b9f58b3701 | ||
|
|
91f3f036ae | ||
|
|
4ef90e26cf | ||
|
|
f7182d6393 | ||
|
|
99861facc9 | ||
|
|
a5728daeb0 | ||
|
|
a4ac2eeb89 | ||
|
|
a5a320b889 | ||
|
|
8f927c29e3 | ||
|
|
a739b27d14 | ||
|
|
2f2ca97ec1 | ||
|
|
114186ce06 | ||
|
|
8187a7f5a9 | ||
|
|
f8319c720a | ||
|
|
3d93fe3d18 | ||
|
|
b4373ae1ed | ||
|
|
a37effd42d | ||
|
|
655cd20a01 | ||
|
|
a13dbf2bda | ||
|
|
f1affc2dbb | ||
|
|
cbc0f95498 | ||
|
|
b3f8b06fea | ||
|
|
ed4c19ac79 | ||
|
|
37bd7e34a0 | ||
|
|
c1d05f8b2f | ||
|
|
9c715dd381 | ||
|
|
a720a636d3 | ||
|
|
5322d6e900 | ||
|
|
9d849f12c0 | ||
|
|
18dc5354bd | ||
|
|
a013f1f1cc | ||
|
|
e1d236d9cf | ||
|
|
12a2a21c0e | ||
|
|
9ae13a9f97 | ||
|
|
bd64a7de63 | ||
|
|
12a3cd5113 | ||
|
|
1d23e87bd9 | ||
|
|
b23bea8971 | ||
|
|
830ade9ad6 | ||
|
|
ed07248f7d | ||
|
|
9aa5993748 | ||
|
|
e7909beaab | ||
|
|
79e3450a46 | ||
|
|
783204b57f | ||
|
|
1adb2b2caa | ||
|
|
3f579793ca | ||
|
|
8040608e14 | ||
|
|
2d202e2244 | ||
|
|
2e66d278ad | ||
|
|
1bfbf3643d | ||
|
|
5990bf0213 | ||
|
|
5e3db99675 | ||
|
|
a291741c37 | ||
|
|
94ee80d8ea | ||
|
|
f7de66c6a5 | ||
|
|
48d890b354 | ||
|
|
c7a9bd1226 | ||
|
|
c40c7bc62b | ||
|
|
cca7a10322 | ||
|
|
fe6ef1df0a | ||
|
|
3e27567532 | ||
|
|
0a15e079dc | ||
|
|
48f4fc4a0f | ||
|
|
d9396ce453 | ||
|
|
817c5172b6 | ||
|
|
cfdb20892e | ||
|
|
75922f93bc | ||
|
|
42801e3eca | ||
|
|
64321dbe7c | ||
|
|
b11b52b60c | ||
|
|
00e5470642 | ||
|
|
7ee3b8a196 | ||
|
|
585bb57f64 | ||
|
|
6981fe8f8a | ||
|
|
abdad7fbea | ||
|
|
57b35398be | ||
|
|
47398e6e86 | ||
|
|
e775ae9ada | ||
|
|
6a9a321e2b | ||
|
|
0aee4407db | ||
|
|
a816fa561a | ||
|
|
dc745c3cdb | ||
|
|
0b30c83143 | ||
|
|
dc8804933d | ||
|
|
b28f0bff74 | ||
|
|
e96e3c0760 | ||
|
|
b7ea167420 | ||
|
|
744a55f497 | ||
|
|
b55f999efb | ||
|
|
4e09d9e54a | ||
|
|
5afa333fab | ||
|
|
c0946f5c0e | ||
|
|
3ece4fcf8b | ||
|
|
b1193c5d11 | ||
|
|
12b0afd9c6 | ||
|
|
df45d68b74 | ||
|
|
11bf702e02 | ||
|
|
47e2683aa3 | ||
|
|
e4fc00669e | ||
|
|
f64db1271b | ||
|
|
35647a8361 | ||
|
|
a2c8658972 | ||
|
|
38e18cf0a7 | ||
|
|
78c9974036 | ||
|
|
7a60a6804a | ||
|
|
5e6e2835ca | ||
|
|
180083147d | ||
|
|
50b0b5af37 | ||
|
|
9d27b8d47e | ||
|
|
fbb4dc5e59 | ||
|
|
a2296f2365 | ||
|
|
58ff2e6157 | ||
|
|
ff490a2b14 | ||
|
|
067d694036 | ||
|
|
401d807d29 | ||
|
|
eab202f721 | ||
|
|
f726c656b5 | ||
|
|
37dab1c8b6 | ||
|
|
9a5d8522fb | ||
|
|
5abbd07722 | ||
|
|
13579f9473 | ||
|
|
cea9cdcd34 | ||
|
|
ed22155940 | ||
|
|
43497f2ce0 | ||
|
|
80e58f72f8 | ||
|
|
234751a4b3 | ||
|
|
ef567110d0 | ||
|
|
42b89f438b | ||
|
|
b01df2b38e | ||
|
|
b89efb547e | ||
|
|
e6554a5b4a | ||
|
|
c58b13e2f7 | ||
|
|
241372ce61 | ||
|
|
49ed594f53 | ||
|
|
03b76357ff | ||
|
|
97490bb6ef | ||
|
|
dbf5267c5c | ||
|
|
e1ce638d7f | ||
|
|
88b80a0da1 | ||
|
|
d6b571590a | ||
|
|
83f3af340f | ||
|
|
e9cf9b21a8 | ||
|
|
b1645590fe | ||
|
|
a5da4ea53d | ||
|
|
3a082d131f | ||
|
|
28d501ed02 | ||
|
|
a04fd4a9f2 | ||
|
|
2424dc3961 | ||
|
|
58f1515387 | ||
|
|
9ee3da3910 | ||
|
|
cc44b4dd55 | ||
|
|
b8e4ccfc28 | ||
|
|
0fc6757d4a | ||
|
|
ee94cc6bf6 | ||
|
|
f224fe6b40 | ||
|
|
403a1723de | ||
|
|
6cf1960df4 | ||
|
|
8676752f19 | ||
|
|
7012d949c2 | ||
|
|
8344274011 | ||
|
|
2bda50ad4a | ||
|
|
b0953310c2 | ||
|
|
65910295db | ||
|
|
7a957bd6ec | ||
|
|
292ce779a1 | ||
|
|
924b21cdfc | ||
|
|
7b539e63bd | ||
|
|
d37fef94fe | ||
|
|
93a1853317 | ||
|
|
9944f98bde | ||
|
|
05138232f4 | ||
|
|
83d7085375 | ||
|
|
a8a3915c8d | ||
|
|
83bcf53583 | ||
|
|
875d9544ec | ||
|
|
7ce2a0bbea | ||
|
|
a3b9ea6a42 | ||
|
|
ca2c811859 | ||
|
|
b09bb0c3c5 | ||
|
|
a68ea1abe5 | ||
|
|
02a9aaab60 | ||
|
|
efd2ddba71 | ||
|
|
e10c5771d8 | ||
|
|
b685b38565 | ||
|
|
15822fee8e | ||
|
|
e96e218025 | ||
|
|
a11263ce9e | ||
|
|
4417201582 | ||
|
|
6a85ab743d | ||
|
|
4434b0e0e5 | ||
|
|
7c8381624b | ||
|
|
e470531210 | ||
|
|
182cf62a3b | ||
|
|
3e666df896 | ||
|
|
20aca144fa | ||
|
|
25d9f7b529 | ||
|
|
306a7b3041 | ||
|
|
f2311f294e | ||
|
|
b51f551b4b | ||
|
|
af751d56ce | ||
|
|
f4d66d9cbf | ||
|
|
37d839504c | ||
|
|
e8e696dcea | ||
|
|
4db0faeda4 | ||
|
|
b8ca1a15fb | ||
|
|
407ee74a55 | ||
|
|
67bcc52b25 | ||
|
|
9afc94c050 | ||
|
|
9d9283e05f | ||
|
|
5692625922 | ||
|
|
7e07573fa5 | ||
|
|
1f08f33600 | ||
|
|
1ee3dc0ff3 | ||
|
|
3626f24b5f | ||
|
|
4bced4dbea | ||
|
|
737244fb60 | ||
|
|
22adbddd88 | ||
|
|
6c5fecc4d2 | ||
|
|
97017e2bd5 | ||
|
|
3145e381a0 | ||
|
|
bd62736002 | ||
|
|
6884d5e8dd | ||
|
|
27235b0f77 | ||
|
|
24dc4ba7a5 | ||
|
|
a01a28de0e | ||
|
|
9fa3b1db34 | ||
|
|
3193a5e0ef | ||
|
|
3137750a58 | ||
|
|
20068a40d4 | ||
|
|
ef5980e90b | ||
|
|
04a3fe2f4c | ||
|
|
33467c62fa | ||
|
|
a3e94dbaef | ||
|
|
b4e39f2b73 | ||
|
|
b4e409db4d | ||
|
|
eff07bfb1e | ||
|
|
7f305f498b | ||
|
|
bfb7de896c | ||
|
|
2c8c4bede7 | ||
|
|
b079b3ef79 | ||
|
|
a8c8ee1539 | ||
|
|
8f14ccda86 | ||
|
|
a66f001033 | ||
|
|
ca79ceb1b9 | ||
|
|
0abac47ff2 | ||
|
|
5038588d70 | ||
|
|
19dab9ef4d | ||
|
|
374fe85ce9 | ||
|
|
c1120aede3 | ||
|
|
e2f93ec17e | ||
|
|
78adb757da | ||
|
|
9a6f88b81b | ||
|
|
3cd6bab7e9 | ||
|
|
1918fe24fc | ||
|
|
5262368b61 | ||
|
|
d6a8b70852 | ||
|
|
d61844436d | ||
|
|
32067fe800 | ||
|
|
e41da35a02 | ||
|
|
b865d15adf | ||
|
|
42e0cda2e2 | ||
|
|
ad70cfb8d7 | ||
|
|
caaf554371 | ||
|
|
73cabc1b25 | ||
|
|
9590eecacd | ||
|
|
8eb3a47844 | ||
|
|
9ae860a66a | ||
|
|
ceab807f98 | ||
|
|
6b61132f15 | ||
|
|
cdcf6f9b0a | ||
|
|
573cfa8b75 | ||
|
|
e1f2313054 | ||
|
|
23a99f3a8c | ||
|
|
53393a350c | ||
|
|
56e83d3fe3 | ||
|
|
8aa545f920 | ||
|
|
5f459ac924 | ||
|
|
5ce35363d9 | ||
|
|
913fa4ba5b | ||
|
|
7a50f97773 | ||
|
|
0bd5ad9496 | ||
|
|
2c4be592ad | ||
|
|
2183639719 | ||
|
|
0602150cd8 | ||
|
|
c7327c8b91 | ||
|
|
e5e47795c1 | ||
|
|
0980208cce | ||
|
|
93d6d2ed3e | ||
|
|
b65139f520 | ||
|
|
62ff9ac94b | ||
|
|
584c2860e4 | ||
|
|
4a77d8b134 | ||
|
|
7e89822cbf | ||
|
|
8231e03a94 | ||
|
|
26e47d7a10 | ||
|
|
d02ee4dc53 | ||
|
|
1f4f5e3094 | ||
|
|
fb14c4e872 | ||
|
|
ec503f4963 | ||
|
|
a0869349bd | ||
|
|
0f71c01632 | ||
|
|
c2723fe9dd | ||
|
|
3936edb3b7 | ||
|
|
12a1c3fe6d | ||
|
|
5b1e7c3451 | ||
|
|
9b9cc092dc | ||
|
|
5cc0bf54be | ||
|
|
27e053dcd2 | ||
|
|
aaa0678c18 | ||
|
|
cd03ed354f | ||
|
|
10f3551c4f | ||
|
|
6bb1f260ea | ||
|
|
d889c675a0 | ||
|
|
757f6982bd | ||
|
|
d88581ac88 | ||
|
|
2b60c24c86 | ||
|
|
99f84e891e | ||
|
|
efa0672241 | ||
|
|
987a3026ac | ||
|
|
bbf33df04c | ||
|
|
8b81e2e279 | ||
|
|
3d8870c342 | ||
|
|
1a923cefaf | ||
|
|
eed62c36b9 | ||
|
|
712c8ab2dd | ||
|
|
839a691a67 | ||
|
|
dbf0256b1c | ||
|
|
e910260375 | ||
|
|
7c9510a6c9 | ||
|
|
3030d474e2 | ||
|
|
d9c0b8cd20 | ||
|
|
51fbf1b1a5 | ||
|
|
ba33b5e1c6 | ||
|
|
81d9037807 | ||
|
|
79c894dcfd | ||
|
|
e4c98cf39f | ||
|
|
ed1e080fbe | ||
|
|
afef10189b | ||
|
|
1a66319a6d | ||
|
|
7a1470ef7b | ||
|
|
be09577db1 | ||
|
|
f91a0d4cd0 | ||
|
|
4589e6f329 | ||
|
|
7375c9e9e3 | ||
|
|
ccd71a8023 | ||
|
|
3a76d176c5 | ||
|
|
6626be8e1c | ||
|
|
171a7841b3 | ||
|
|
611dcc0189 | ||
|
|
dd9ea5f27e | ||
|
|
cb20e14822 | ||
|
|
b9aa099a1e | ||
|
|
1e8bc10ad1 | ||
|
|
ba40b1815f | ||
|
|
cf2c56e088 | ||
|
|
896758121b | ||
|
|
f0f9726ede | ||
|
|
ddb3990c11 | ||
|
|
8a72e3b7ac | ||
|
|
aae1a65cba | ||
|
|
fb017c3903 | ||
|
|
ef31f56eab | ||
|
|
771cdd6ebe | ||
|
|
0c1b9195d7 | ||
|
|
1fe58810a0 | ||
|
|
3cde9bcdd5 | ||
|
|
0c3b73f4d3 | ||
|
|
25883d00ce | ||
|
|
d04bbfc640 | ||
|
|
7da837f49e | ||
|
|
9709ffe388 | ||
|
|
f7cc656288 | ||
|
|
3ba979e513 | ||
|
|
eadb4a108c | ||
|
|
8b44816520 | ||
|
|
2fb673acf0 | ||
|
|
8401f09475 | ||
|
|
c4400ded28 | ||
|
|
fc37789727 | ||
|
|
abb27ac811 | ||
|
|
fbf5fb4aab | ||
|
|
3887a6ae44 | ||
|
|
cfb342cd74 | ||
|
|
2d693145d9 | ||
|
|
d62a2480f4 | ||
|
|
b30fb1d9c7 | ||
|
|
d1f61c0bba | ||
|
|
5d04f3ea99 | ||
|
|
bd37e4a9df | ||
|
|
3cdfdfa8e8 | ||
|
|
fb7c7a723b | ||
|
|
8171c1ee5e | ||
|
|
0366f6235c | ||
|
|
7890ac9db5 | ||
|
|
fd8517e047 | ||
|
|
7a53708ccc | ||
|
|
ab0361bf27 | ||
|
|
eb40540f0d | ||
|
|
c1f8116a5a | ||
|
|
40aadf77f7 | ||
|
|
adafb6b183 | ||
|
|
d38a7169b8 | ||
|
|
53705f214a | ||
|
|
155bb80118 | ||
|
|
de77f2e18b | ||
|
|
b2f05b4247 | ||
|
|
88292aa179 | ||
|
|
79ba910f52 | ||
|
|
30402b26e8 | ||
|
|
cb6401c7ab | ||
|
|
435e99e81a | ||
|
|
4b7167d5ef | ||
|
|
9887ec2d38 | ||
|
|
edb61e2423 | ||
|
|
8b134892ce | ||
|
|
21cb82a05d | ||
|
|
412c5c557a | ||
|
|
81e31319c7 | ||
|
|
97ec28d2c8 | ||
|
|
c6906c42fd | ||
|
|
7921db3ba9 | ||
|
|
b12413cbff | ||
|
|
07cd1738f1 | ||
|
|
b8aa7e64b3 | ||
|
|
055ed62cb7 | ||
|
|
578c70d272 | ||
|
|
d43f0435d0 | ||
|
|
52978fa237 | ||
|
|
f266a9e964 | ||
|
|
01cb27e6f5 | ||
|
|
551ed64e98 | ||
|
|
f0b4660719 | ||
|
|
38730ea534 | ||
|
|
a955f55fc6 | ||
|
|
a42cdaaf91 | ||
|
|
0633707ec6 | ||
|
|
8768daa06f | ||
|
|
fb0a7b0ae4 | ||
|
|
f8b6b8fb45 | ||
|
|
888b6daa2a | ||
|
|
29c4c42547 | ||
|
|
107e09aefb | ||
|
|
811c373e4c | ||
|
|
1812ec296e | ||
|
|
59cdd81aec | ||
|
|
9297afc02a | ||
|
|
a8d8be3307 | ||
|
|
53ec6c817c | ||
|
|
b7c4ec8358 | ||
|
|
3426ff307c | ||
|
|
cddb713b2f | ||
|
|
1871fe6177 | ||
|
|
83a8558f23 | ||
|
|
dbc155454f | ||
|
|
80e251acfb | ||
|
|
a597bd7882 | ||
|
|
a53bb53c12 | ||
|
|
4fa7aa54a8 | ||
|
|
a9490e1299 | ||
|
|
2bf36f1cfe | ||
|
|
c781d5eff7 | ||
|
|
e8d84795d5 | ||
|
|
b351a781a9 | ||
|
|
97d9e96e2b | ||
|
|
625b8c3bce | ||
|
|
edd4a4cc2c | ||
|
|
b305ccad1d | ||
|
|
14ca6f1d08 | ||
|
|
ad4e6d6596 | ||
|
|
e94d2b5340 | ||
|
|
0241bf0913 | ||
|
|
36885eb6dc | ||
|
|
c1917855d0 | ||
|
|
0272073636 | ||
|
|
063ef1e3a9 | ||
|
|
4ec7af3265 | ||
|
|
f09f472f26 | ||
|
|
2e365b67ea | ||
|
|
ee753e5c95 | ||
|
|
8b53d28721 | ||
|
|
63ad7fd205 | ||
|
|
c31b99b1dc | ||
|
|
851ec71f4a | ||
|
|
e30b4f7c7e | ||
|
|
00f9f7ba52 | ||
|
|
564f926213 | ||
|
|
0cf891bb4d | ||
|
|
fba873dab9 | ||
|
|
322853f696 | ||
|
|
19c3421e92 | ||
|
|
206d6c68bb |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -14,10 +14,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
|
||||
4
.github/workflows/create-release.yml
vendored
4
.github/workflows/create-release.yml
vendored
@@ -12,10 +12,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
@@ -68,6 +69,7 @@ render.experimental.xml
|
||||
.idea/**/caches/
|
||||
.idea/**/libraries/
|
||||
.idea/**/shelf/
|
||||
.idea/**/codeStyles
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/.name
|
||||
|
||||
10
.idea/misc.xml
generated
10
.idea/misc.xml
generated
@@ -1,10 +0,0 @@
|
||||
<?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="Android Studio default JDK" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,12 +1,12 @@
|
||||
# Amethyst Privacy Policy & Terms of Use
|
||||
|
||||
Effective as of Jan 20, 2023.
|
||||
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
|
||||
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
|
||||
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
|
||||
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
|
||||
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
|
||||
|
||||
|
||||
44
README.md
44
README.md
@@ -4,6 +4,15 @@
|
||||
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
|
||||
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
|
||||
alt="Get it on Google Play"
|
||||
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
|
||||
@@ -23,7 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] Relay Sets (home, dms, public chats, global)
|
||||
- [x] User/Note Tagging (NIP-08, NIP-10)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
@@ -32,12 +41,25 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Internationalization
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Verifiable static content in URLs (NIP-94)
|
||||
- [x] Login with QR
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
- [x] Accessible uploads
|
||||
- [x] Bounty support (nostrbounties.com)
|
||||
- [x] De-googled F-Droid flavor
|
||||
- [x] External Identity Support (NIP-39)
|
||||
- [x] Multiple Accounts
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] 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)
|
||||
@@ -50,6 +72,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [ ] Account Creation / Backup Guidance (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
|
||||
|
||||
# Development Overview
|
||||
|
||||
## Overall Architecture
|
||||
@@ -74,7 +97,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
|
||||
## Setup
|
||||
|
||||
Make sure to have the following pre-requisites installed:
|
||||
1. Java 11
|
||||
1. Java 17
|
||||
2. Android Studio
|
||||
3. Android 8.0+ Phone or Emulation setup
|
||||
|
||||
@@ -104,8 +127,15 @@ Build the app:
|
||||
```
|
||||
|
||||
## Installing on device
|
||||
|
||||
For the F-Droid build:
|
||||
```bash
|
||||
./gradlew installDebug
|
||||
./gradlew installFdroidDebug
|
||||
```
|
||||
|
||||
For the Play build:
|
||||
```bash
|
||||
./gradlew installPlayDebug
|
||||
```
|
||||
|
||||
## How to Deploy
|
||||
@@ -149,6 +179,12 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
|
||||
</a>
|
||||
|
||||
# MIT License
|
||||
|
||||
Copyright (c) 2023 Vitor Pamplona
|
||||
|
||||
BIN
amethyst.png
BIN
amethyst.png
Binary file not shown.
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 51 KiB |
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 96
|
||||
versionName "0.26.0"
|
||||
versionCode 138
|
||||
versionName "0.40.4"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -38,6 +38,7 @@ android {
|
||||
|
||||
productFlavors {
|
||||
play {
|
||||
getIsDefault().set(true)
|
||||
dimension "channel"
|
||||
}
|
||||
|
||||
@@ -56,12 +57,12 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '11'
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -77,11 +78,15 @@ android {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -103,11 +108,13 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
|
||||
implementation 'net.engawapg.lib:zoomable:1.4.2'
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
@@ -120,34 +127,33 @@ dependencies {
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// 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.fasterxml.jackson.module:jackson-module-kotlin:2.15.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// link preview
|
||||
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.4'
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:2.2.2"
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
// view gifs
|
||||
implementation "io.coil-kt:coil-gif:2.2.2"
|
||||
implementation "io.coil-kt:coil-gif:$coil_version"
|
||||
// view svgs
|
||||
implementation("io.coil-kt:coil-svg:2.2.2")
|
||||
implementation "io.coil-kt:coil-svg:$coil_version"
|
||||
|
||||
// create blurhash
|
||||
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
|
||||
|
||||
// Rendering clickable text
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
// tabs for user profiles
|
||||
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"
|
||||
|
||||
@@ -170,8 +176,8 @@ dependencies {
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "io.mockk:mockk:1.13.4"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
testImplementation 'io.mockk:mockk:1.13.5'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
|
||||
@@ -181,3 +187,35 @@ dependencies {
|
||||
ktlint {
|
||||
disabledRules.set(["no-wildcard-imports"])
|
||||
}
|
||||
|
||||
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
|
||||
// NB: Android Studio can't find the imports; this does not affect the
|
||||
// actual build since Gradle can find them just fine.
|
||||
import com.android.tools.profgen.ArtProfileKt
|
||||
import com.android.tools.profgen.ArtProfileSerializer
|
||||
import com.android.tools.profgen.DexFile
|
||||
|
||||
project.afterEvaluate {
|
||||
tasks.each { task ->
|
||||
if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) {
|
||||
task.doLast {
|
||||
outputs.files.each { file ->
|
||||
if (file.name.endsWith(".profm")) {
|
||||
println("Sorting ${file} ...")
|
||||
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
|
||||
def profile = ArtProfileKt.ArtProfile(file)
|
||||
def keys = new ArrayList(profile.profileData.keySet())
|
||||
def sortedData = new LinkedHashMap()
|
||||
Collections.sort keys, new DexFile.Companion()
|
||||
keys.each { key -> sortedData[key] = profile.profileData[key] }
|
||||
new FileOutputStream(file).with {
|
||||
write(version.magicBytes$profgen)
|
||||
write(version.versionBytes$profgen)
|
||||
version.write$profgen(it, sortedData, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ImageUploadTesting {
|
||||
|
||||
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
"image/gif",
|
||||
ImgurServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore
|
||||
fun testNostrBuildUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
"image/gif",
|
||||
NostrBuildServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrImgUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
"image/gif",
|
||||
NostrImgServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent.Companion.createEncryptionPrivateKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PrivateZapTests {
|
||||
|
||||
@Test
|
||||
fun testPollZap() {
|
||||
val poll = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "New poll \n\n #zappoll",
|
||||
"created_at": 1682440713,
|
||||
"id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288",
|
||||
"kind": 6969,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279",
|
||||
"tags": [
|
||||
[
|
||||
"poll_option",
|
||||
"0",
|
||||
"Test 1"
|
||||
],
|
||||
[
|
||||
"poll_option",
|
||||
"1",
|
||||
"Test 2"
|
||||
],
|
||||
[
|
||||
"value_maximum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"value_minimum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"consensus_threshold",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"closed_at",
|
||||
"null"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
poll,
|
||||
setOf("wss://relay.damus.io/"),
|
||||
loggedIn,
|
||||
0,
|
||||
"",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKind1PrivateZap() {
|
||||
val textNote = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
"created_at": 1682369982,
|
||||
"id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1",
|
||||
"kind": 1,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1",
|
||||
"tags": [
|
||||
[
|
||||
"p",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
textNote,
|
||||
setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
|
||||
loggedIn,
|
||||
null,
|
||||
"test",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,4 +77,13 @@ class TranslationsTest {
|
||||
"pt"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNostrEvents() {
|
||||
assertTranslateContains(
|
||||
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
"en"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun TranslateableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) = ExpandableRichTextViewer(
|
||||
content,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun TranslatableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) = ExpandableRichTextViewer(
|
||||
content,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
@@ -2,8 +2,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
@@ -16,7 +19,7 @@
|
||||
android:name=".Amethyst"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/amethyst"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:supportsRtl="true"
|
||||
@@ -43,6 +46,14 @@
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrwalletconnect" />
|
||||
<data android:scheme="nostr+walletconnect" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
android:value="" />
|
||||
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -11,6 +11,9 @@ import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47URI
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.toHex
|
||||
@@ -26,10 +29,7 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
|
||||
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean,
|
||||
val current: Boolean,
|
||||
val displayName: String?,
|
||||
val profilePicture: String?
|
||||
val hasPrivKey: Boolean = false
|
||||
)
|
||||
|
||||
private object PrefKeys {
|
||||
@@ -37,8 +37,6 @@ private object PrefKeys {
|
||||
const val SAVED_ACCOUNTS = "all_saved_accounts"
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val DISPLAY_NAME = "display_name"
|
||||
const val PROFILE_PICTURE_URL = "profile_picture"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
@@ -46,8 +44,12 @@ private object PrefKeys {
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val DEFAULT_ZAPTYPE = "defaultZapType"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
@@ -56,53 +58,73 @@ private val gson = GsonBuilder().create()
|
||||
object LocalPreferences {
|
||||
private const val comma = ","
|
||||
|
||||
private var currentAccount: String?
|
||||
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
set(npub) {
|
||||
val prefs = encryptedPreferences()
|
||||
prefs.edit().apply {
|
||||
private var _currentAccount: String? = null
|
||||
|
||||
private fun currentAccount(): String? {
|
||||
if (_currentAccount == null) {
|
||||
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
}
|
||||
return _currentAccount
|
||||
}
|
||||
|
||||
private fun updateCurrentAccount(npub: String) {
|
||||
if (_currentAccount != npub) {
|
||||
_currentAccount = npub
|
||||
|
||||
encryptedPreferences().edit().apply {
|
||||
putString(PrefKeys.CURRENT_ACCOUNT, npub)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private val savedAccounts: List<String>
|
||||
get() = encryptedPreferences()
|
||||
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
private var _savedAccounts: List<String>? = null
|
||||
|
||||
private fun savedAccounts(): List<String> {
|
||||
if (_savedAccounts == null) {
|
||||
_savedAccounts = encryptedPreferences()
|
||||
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
}
|
||||
return _savedAccounts!!
|
||||
}
|
||||
|
||||
private fun updateSavedAccounts(accounts: List<String>) {
|
||||
if (_savedAccounts != accounts) {
|
||||
_savedAccounts = accounts
|
||||
|
||||
encryptedPreferences().edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private val prefsDirPath: String
|
||||
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
|
||||
|
||||
private fun addAccount(npub: String) {
|
||||
val accounts = savedAccounts.toMutableList()
|
||||
val accounts = savedAccounts().toMutableList()
|
||||
if (npub !in accounts) {
|
||||
accounts.add(npub)
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
val prefs = encryptedPreferences()
|
||||
prefs.edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
}.apply()
|
||||
}
|
||||
|
||||
private fun setCurrentAccount(account: Account) {
|
||||
val npub = account.userProfile().pubkeyNpub()
|
||||
currentAccount = npub
|
||||
updateCurrentAccount(npub)
|
||||
addAccount(npub)
|
||||
}
|
||||
|
||||
fun switchToAccount(npub: String) {
|
||||
currentAccount = npub
|
||||
updateCurrentAccount(npub)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the account from the app level shared preferences
|
||||
*/
|
||||
private fun removeAccount(npub: String) {
|
||||
val accounts = savedAccounts.toMutableList()
|
||||
val accounts = savedAccounts().toMutableList()
|
||||
if (accounts.remove(npub)) {
|
||||
val prefs = encryptedPreferences()
|
||||
prefs.edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
}.apply()
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,11 +164,11 @@ object LocalPreferences {
|
||||
removeAccount(npub)
|
||||
deleteUserPreferenceFile(npub)
|
||||
|
||||
if (savedAccounts.isEmpty()) {
|
||||
if (savedAccounts().isEmpty()) {
|
||||
val appPrefs = encryptedPreferences()
|
||||
appPrefs.edit().clear().apply()
|
||||
} else if (currentAccount == npub) {
|
||||
currentAccount = savedAccounts.elementAt(0)
|
||||
} else if (currentAccount() == npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,17 +178,8 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
fun allSavedAccounts(): List<AccountInfo> {
|
||||
return savedAccounts.map { npub ->
|
||||
val prefs = encryptedPreferences(npub)
|
||||
val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
|
||||
|
||||
AccountInfo(
|
||||
npub = npub,
|
||||
hasPrivKey = hasPrivKey,
|
||||
current = npub == currentAccount,
|
||||
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
|
||||
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
|
||||
)
|
||||
return savedAccounts().map { npub ->
|
||||
AccountInfo(npub = npub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,15 +195,17 @@ object LocalPreferences {
|
||||
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
|
||||
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
|
||||
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences(currentAccount).apply {
|
||||
encryptedPreferences(currentAccount()).apply {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
@@ -208,6 +223,25 @@ object LocalPreferences {
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
|
||||
val defaultZapType = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
|
||||
object : TypeToken<LnZapEvent.ZapType>() {}.type
|
||||
) ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
|
||||
object : TypeToken<ServersAvailable>() {}.type
|
||||
) ?: ServersAvailable.IMGUR
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
gson.fromJson(it, Nip47URI::class.java)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val latestContactList = try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java)
|
||||
@@ -230,9 +264,10 @@ object LocalPreferences {
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
|
||||
return Account(
|
||||
val a = Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
@@ -241,26 +276,32 @@ object LocalPreferences {
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
hideDeleteRequestInfo,
|
||||
defaultZapType,
|
||||
defaultFileServer,
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
latestContactList
|
||||
)
|
||||
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences(currentAccount).edit().apply {
|
||||
encryptedPreferences(currentAccount()).edit().apply {
|
||||
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences(currentAccount).run {
|
||||
encryptedPreferences(currentAccount()).run {
|
||||
return getLong(PrefKeys.LAST_READ(route), 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun migrateSingleUserPrefs() {
|
||||
if (currentAccount != null) return
|
||||
if (currentAccount() != null) return
|
||||
|
||||
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
|
||||
val npub = Hex.decode(pubkey).toNpub()
|
||||
@@ -289,14 +330,14 @@ object LocalPreferences {
|
||||
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
|
||||
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
|
||||
userPrefs.putBoolean(
|
||||
PrefKeys.HIDE_DELETE_REQUEST_INFO,
|
||||
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
|
||||
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
|
||||
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
addAccount(npub)
|
||||
currentAccount = npub
|
||||
updateCurrentAccount(npub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
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 java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object NotificationCache {
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
@@ -41,23 +38,14 @@ object NotificationCache {
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
|
||||
@@ -35,8 +35,8 @@ object ServiceManager {
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrAccountDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
@@ -71,6 +71,7 @@ object ServiceManager {
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ object VideoCache {
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
fun get(context: Context): CacheDataSource.Factory {
|
||||
fun init(context: Context) {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
@@ -35,7 +35,9 @@ object VideoCache {
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
fun get(): CacheDataSource.Factory {
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,9 @@ import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
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 java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
|
||||
|
||||
@@ -20,7 +15,7 @@ class AntiSpamFilter {
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event): Boolean {
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
val idHex = event.id
|
||||
|
||||
// if short message, ok
|
||||
@@ -33,7 +28,7 @@ class AntiSpamFilter {
|
||||
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.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
|
||||
|
||||
// Log down offenders
|
||||
if (spamMessages.get(hash) == null) {
|
||||
@@ -60,23 +55,14 @@ class AntiSpamFilter {
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class Channel(val idHex: String) {
|
||||
@@ -27,12 +29,16 @@ class Channel(val idHex: String) {
|
||||
notes[note.idHex] = note
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.refresh()
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
@@ -67,7 +73,18 @@ class Channel(val idHex: String) {
|
||||
}
|
||||
|
||||
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
|
||||
fun refresh() {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
|
||||
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
|
||||
"grownostr", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
|
||||
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
class HashtagIcon(
|
||||
val icon: Int,
|
||||
val description: String,
|
||||
val color: Color,
|
||||
val modifier: Modifier
|
||||
)
|
||||
@@ -38,14 +38,14 @@ fun HexKey.toDisplayHexKey(): String {
|
||||
}
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.toByteArray()
|
||||
|
||||
return if (key.startsWith("nsec")) {
|
||||
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 {
|
||||
} else if (pubKeyParsed != null) {
|
||||
pubKeyParsed
|
||||
} else {
|
||||
Hex.decode(key)
|
||||
}
|
||||
}
|
||||
@@ -84,15 +84,15 @@ fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
} else if (key.startsWith("nprofile", true)) {
|
||||
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkeyRelay, restOfWord)
|
||||
} else if (key.startsWith("nevent", true)) {
|
||||
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
|
||||
} else if (key.startsWith("nevent1", true)) {
|
||||
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteRelayId, restOfWord)
|
||||
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
|
||||
} else if (key.startsWith("naddr1", true)) {
|
||||
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(address, "") // no way to know when they address ends and dirt begins
|
||||
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
@@ -1,52 +1,42 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
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.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
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.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import nostr.postr.toNpub
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object LocalCache {
|
||||
val metadataParser = jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.readerFor(UserMetadata::class.java)
|
||||
val metadataParser by lazy {
|
||||
jacksonObjectMapper()
|
||||
.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 users = ConcurrentHashMap<HexKey, User>(5000)
|
||||
val notes = ConcurrentHashMap<HexKey, Note>(5000)
|
||||
val channels = ConcurrentHashMap<HexKey, Channel>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
|
||||
|
||||
val awaitingPaymentRequests =
|
||||
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
if (isValidHexNpub(key)) {
|
||||
@@ -157,6 +147,18 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BookmarkListEvent) {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
|
||||
if (event.dTag() == "bookmark") {
|
||||
user.updateBookmark(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)}")
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
|
||||
@@ -174,7 +176,7 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
@@ -195,7 +197,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LongTextNoteEvent, relay: Relay?) {
|
||||
@@ -210,7 +212,7 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
@@ -224,10 +226,46 @@ object LocalCache {
|
||||
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PollNoteEvent, relay: Relay? = null) {
|
||||
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
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
@@ -238,7 +276,7 @@ object LocalCache {
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList<Note>())
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,8 +294,6 @@ object LocalCache {
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
author.updateAcceptedBadges(note)
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +306,6 @@ object LocalCache {
|
||||
// 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, awardDefinition)
|
||||
@@ -280,7 +315,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@@ -290,12 +325,10 @@ object LocalCache {
|
||||
|
||||
fun consume(event: ContactListEvent) {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
val follows = event.unverifiedFollowKeySet()
|
||||
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !follows.isNullOrEmpty()) {
|
||||
// Saves relay list only if it's a user that is currently been seen
|
||||
// avoids processing empty contact lists.
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
|
||||
user.updateContactList(event)
|
||||
|
||||
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
|
||||
}
|
||||
}
|
||||
@@ -326,7 +359,7 @@ object LocalCache {
|
||||
recipient.addMessage(author, note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: DeletionEvent) {
|
||||
@@ -351,9 +384,23 @@ object LocalCache {
|
||||
masterNote.removeBoost(deleteNote)
|
||||
masterNote.removeReaction(deleteNote)
|
||||
masterNote.removeZap(deleteNote)
|
||||
masterNote.removeZapPayment(deleteNote)
|
||||
masterNote.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
val channel = deleteNote.channel()
|
||||
channel?.removeNote(deleteNote)
|
||||
|
||||
if (deleteNote.event is PrivateDmEvent) {
|
||||
val author = deleteNote.author
|
||||
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
|
||||
|
||||
if (recipient != null && author != null) {
|
||||
author.removeMessage(recipient, deleteNote)
|
||||
recipient.removeMessage(author, deleteNote)
|
||||
}
|
||||
}
|
||||
|
||||
notes.remove(deleteNote.idHex)
|
||||
|
||||
deletedAtLeastOne = true
|
||||
@@ -361,7 +408,7 @@ object LocalCache {
|
||||
}
|
||||
|
||||
if (deletedAtLeastOne) {
|
||||
live.invalidateData()
|
||||
// refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +434,7 @@ object LocalCache {
|
||||
it.addBoost(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ReactionEvent) {
|
||||
@@ -397,7 +444,6 @@ object LocalCache {
|
||||
if (note.event != null) return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
|
||||
@@ -426,6 +472,8 @@ object LocalCache {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ReportEvent, relay: Relay?) {
|
||||
@@ -442,7 +490,7 @@ object LocalCache {
|
||||
|
||||
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) }
|
||||
val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
@@ -456,6 +504,8 @@ object LocalCache {
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ChannelCreateEvent) {
|
||||
@@ -472,7 +522,7 @@ object LocalCache {
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +542,7 @@ object LocalCache {
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -519,17 +569,16 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val replyTo = event.replyTos()
|
||||
val replyTo = event.tagsWithoutCitations()
|
||||
.filter { it != event.channel() }
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
.filter { it.event !is ChannelCreateEvent }
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
@@ -540,7 +589,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@@ -553,19 +602,17 @@ 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 zapRequest = event.zapRequest?.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>()
|
||||
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
|
||||
)
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
@@ -583,6 +630,8 @@ object LocalCache {
|
||||
mentions.forEach {
|
||||
it.addZap(zapRequest, note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapRequestEvent) {
|
||||
@@ -606,6 +655,138 @@ object LocalCache {
|
||||
mentions.forEach {
|
||||
it.addZap(note, null)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileHeaderEvent, 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
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileStorageHeaderEvent, 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
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: HighlightEvent, 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
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
// Adds to user profile
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileStorageEvent, 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
|
||||
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val stream = FileOutputStream(File(cachePath, event.id))
|
||||
stream.write(event.decode())
|
||||
stream.close()
|
||||
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
}
|
||||
|
||||
// this is an invalid event. But we don't need to keep the data in memory.
|
||||
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
|
||||
|
||||
note.loadEvent(eventNoData, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentRequestEvent) {
|
||||
// Does nothing without a response callback.
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
zappedNote?.addZapPayment(note, null)
|
||||
|
||||
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentResponseEvent) {
|
||||
val requestId = event.requestId()
|
||||
val pair = awaitingPaymentRequests[requestId] ?: return
|
||||
|
||||
val (zappedNote, responseCallback) = pair
|
||||
|
||||
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
requestNote?.let { request ->
|
||||
zappedNote?.addZapPayment(request, note)
|
||||
}
|
||||
|
||||
if (responseCallback != null) {
|
||||
responseCallback(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun findUsersStartingWith(username: String): List<User> {
|
||||
@@ -619,6 +800,7 @@ object LocalCache {
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
return notes.values.filter {
|
||||
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
|
||||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
|
||||
(it.event is ChannelMessageEvent && it.event?.content()?.contains(text, true) ?: false) ||
|
||||
it.idHex.startsWith(text, true) ||
|
||||
it.idNote().startsWith(text, true)
|
||||
@@ -656,11 +838,6 @@ object LocalCache {
|
||||
notes.remove(it.idHex)
|
||||
// Doesn't need to clean up the replies and mentions.. Too small to matter.
|
||||
|
||||
// reverts the add
|
||||
val mentions =
|
||||
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
|
||||
?.mapNotNull { checkGetOrCreateUser(it) }
|
||||
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { _ ->
|
||||
it.removeReply(it)
|
||||
@@ -698,40 +875,36 @@ object LocalCache {
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData(this)
|
||||
fun pruneContactLists(userAccount: Account) {
|
||||
var removingContactList = 0
|
||||
users.values.forEach {
|
||||
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
|
||||
it.latestContactList = null
|
||||
removingContactList++
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.invalidateData()
|
||||
println("PRUNE: $removingContactList contact lists")
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData()
|
||||
|
||||
private fun refreshObservers(newNote: Note) {
|
||||
live.invalidateData(newNote)
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheLiveData(val cache: LocalCache) :
|
||||
LiveData<LocalCacheState>(LocalCacheState(cache)) {
|
||||
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
|
||||
|
||||
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)
|
||||
}
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||
if (hasActiveObservers()) {
|
||||
postValue(bundledNewNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(LocalCacheState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheState(val cache: LocalCache)
|
||||
|
||||
@@ -2,29 +2,27 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
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 java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
|
||||
class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun toNEvent() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
override fun address() = address
|
||||
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
|
||||
@@ -48,26 +46,34 @@ open class Note(val idHex: String) {
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
private set
|
||||
|
||||
var lastReactionsDownloadTime: Long? = null
|
||||
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
|
||||
|
||||
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 toNEvent(): String {
|
||||
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
|
||||
open fun address() = (event as? LongTextNoteEvent)?.address()
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun channelHex(): HexKey? {
|
||||
return (event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
}
|
||||
|
||||
fun channel(): Channel? {
|
||||
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
|
||||
open fun createdAt() = event?.createdAt()
|
||||
|
||||
@@ -153,6 +159,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZapPayment(note: Note) {
|
||||
if (zapPayments[note] != null) {
|
||||
zapPayments = zapPayments.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPayments.containsValue(note)) {
|
||||
val toRemove = zapPayments.filterValues { it == note }
|
||||
zapPayments = zapPayments.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
@@ -160,6 +177,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
@@ -170,6 +188,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (note !in reactions) {
|
||||
reactions = reactions + note
|
||||
@@ -196,17 +225,27 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isZappedBy(user: User): Boolean {
|
||||
fun isZappedBy(user: User, account: Account): Boolean {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any { it.key.author == user }
|
||||
return zaps.any {
|
||||
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
} || zapPayments.any {
|
||||
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
|
||||
val response = if (zapResponseEvent != null) {
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
|
||||
}
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
return reactions.any { it.author == user }
|
||||
return reactions.any { it.author === user }
|
||||
}
|
||||
|
||||
fun isBoostedBy(user: User): Boolean {
|
||||
return boosts.any { it.author == user }
|
||||
return boosts.any { it.author === user }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
@@ -227,63 +266,85 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.filter { it.amount != null }
|
||||
.associate {
|
||||
it.lnInvoice() to it.amount
|
||||
}
|
||||
.toMap()
|
||||
|
||||
val completedPayments = if (privKey != null && walletServicePubkey != null) {
|
||||
// Payments confirmed by the User's Wallet
|
||||
zapPayments
|
||||
.asSequence()
|
||||
.filter {
|
||||
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
|
||||
response is PayInvoiceSuccessResponse
|
||||
}
|
||||
.associate {
|
||||
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey)
|
||||
val amount = try {
|
||||
if (lnInvoice == null) {
|
||||
null
|
||||
} else {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
null
|
||||
}
|
||||
lnInvoice to amount
|
||||
}
|
||||
.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
|
||||
}
|
||||
|
||||
fun hasPledgeBy(user: User): Boolean {
|
||||
return replies
|
||||
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
|
||||
.any {
|
||||
val pledgeValue = try {
|
||||
BigDecimal(it.event?.content())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
|
||||
pledgeValue != null && it.author == user
|
||||
}
|
||||
}
|
||||
|
||||
fun pledgedAmountByOthers(): BigDecimal {
|
||||
return replies
|
||||
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
|
||||
.mapNotNull {
|
||||
it.amount
|
||||
}.sumOf { it }
|
||||
try {
|
||||
BigDecimal(it.event?.content())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
}
|
||||
.sumOf { it }
|
||||
}
|
||||
|
||||
fun hasAnyReports(): Boolean {
|
||||
val dayAgo = Date().time / 1000 - 24 * 60 * 60
|
||||
return reports.isNotEmpty() ||
|
||||
(
|
||||
author?.reports?.values?.filter {
|
||||
author?.reports?.values?.any {
|
||||
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
|
||||
}?.isNotEmpty() ?: false
|
||||
} ?: 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> {
|
||||
val matcher = tagSearch.matcher(event?.content() ?: "")
|
||||
val returningList = mutableSetOf<User>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
|
||||
if (tag != null && tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
returningList.add(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun directlyCites(userProfile: User): Boolean {
|
||||
return author == userProfile ||
|
||||
(userProfile in directlyCiteUsers()) ||
|
||||
(event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) ||
|
||||
(event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|
||||
}
|
||||
|
||||
fun isNewThread(): Boolean {
|
||||
return event is RepostEvent || replyTo == null || replyTo?.size == 0
|
||||
}
|
||||
@@ -349,23 +410,14 @@ class NoteLiveSet(u: Note) {
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
|
||||
@@ -7,8 +7,8 @@ data class RelaySetupInfo(
|
||||
val read: Boolean,
|
||||
val write: Boolean,
|
||||
val errorCount: Int = 0,
|
||||
val downloadCount: Int = 0,
|
||||
val uploadCount: Int = 0,
|
||||
val downloadCountInBytes: Int = 0,
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>
|
||||
)
|
||||
|
||||
@@ -53,6 +53,11 @@ class ThreadAssembler {
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
|
||||
loadDown(threadRoot, thread)
|
||||
// adds the replies of the note in case the search for Root
|
||||
// did not added them.
|
||||
note.replies.forEach {
|
||||
loadDown(it, thread)
|
||||
}
|
||||
|
||||
thread.toSet()
|
||||
} else {
|
||||
|
||||
@@ -3,10 +3,6 @@ package com.vitorpamplona.amethyst.model
|
||||
import com.baha.url.preview.BahaUrlPreview
|
||||
import com.baha.url.preview.IUrlPreviewCallback
|
||||
import com.baha.url.preview.UrlInfoItem
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -47,29 +43,4 @@ object UrlCachedPreviewer {
|
||||
).fetchUrlPreview()
|
||||
}
|
||||
}
|
||||
|
||||
fun findUrlsInMessage(message: String): List<String> {
|
||||
return message.split('\n').map { paragraph ->
|
||||
paragraph.split(' ').filter { word: String ->
|
||||
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun preloadPreviewsFor(note: Note) {
|
||||
note.event?.content()?.let {
|
||||
findUrlsInMessage(it).forEach {
|
||||
val removedParamsFromUrl = it.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Preload Images? Isn't this too heavy?
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Do nothing for now.
|
||||
} else if (isValidURL(removedParamsFromUrl)) {
|
||||
previewInfo(it)
|
||||
} else {
|
||||
previewInfo("https://$it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,20 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
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.Bech32
|
||||
import nostr.postr.toNpub
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
|
||||
@@ -28,6 +24,7 @@ class User(val pubkeyHex: String) {
|
||||
var info: UserMetadata? = null
|
||||
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestBookmarkList: BookmarkListEvent? = null
|
||||
|
||||
var notes = setOf<Note>()
|
||||
private set
|
||||
@@ -35,7 +32,7 @@ class User(val pubkeyHex: String) {
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
var latestReportTime: Long = 0
|
||||
var latestEOSEs: Map<String, EOSETime> = emptyMap()
|
||||
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
@@ -50,6 +47,7 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
@@ -75,6 +73,13 @@ class User(val pubkeyHex: String) {
|
||||
return info?.picture
|
||||
}
|
||||
|
||||
fun updateBookmark(event: BookmarkListEvent) {
|
||||
if (event.id == latestBookmarkList?.id) return
|
||||
|
||||
latestBookmarkList = event
|
||||
liveSet?.bookmarks?.invalidateData()
|
||||
}
|
||||
|
||||
fun updateContactList(event: ContactListEvent) {
|
||||
if (event.id == latestContactList?.id) return
|
||||
|
||||
@@ -121,11 +126,6 @@ class User(val pubkeyHex: String) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
liveSet?.reports?.invalidateData()
|
||||
}
|
||||
|
||||
val reportTime = note.createdAt() ?: 0
|
||||
if (reportTime > latestReportTime) {
|
||||
latestReportTime = reportTime
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReport(deleteNote: Note) {
|
||||
@@ -189,6 +189,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
@@ -197,6 +198,15 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelayBeingUsed(relay: Relay, eventTime: Long) {
|
||||
val here = relaysBeingUsed[relay.url]
|
||||
if (here == null) {
|
||||
@@ -236,7 +246,25 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean {
|
||||
return (latestContactList)?.unverifiedFollowKeySet()?.toSet()?.let {
|
||||
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
|
||||
return user.pubkeyHex in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtag(tag: String): Boolean {
|
||||
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
|
||||
return tag in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtagCached(tag: String): Boolean {
|
||||
return latestContactList?.verifiedFollowTagSet?.let {
|
||||
return tag.lowercase() in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingCached(user: User): Boolean {
|
||||
return latestContactList?.verifiedFollowKeySet?.let {
|
||||
return user.pubkeyHex in it
|
||||
} ?: false
|
||||
}
|
||||
@@ -246,25 +274,29 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun transientFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun cachedFollowingKeySet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowKeySet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingTagSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowTagSet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowCount(): Int? {
|
||||
return latestContactList?.verifiedFollowKeySet?.size
|
||||
}
|
||||
|
||||
fun cachedFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = privateChatrooms[user] ?: return false
|
||||
|
||||
return messagesToUser.roomMessages.any { this == it.author }
|
||||
return messagesToUser.roomMessages.any { this === it.author }
|
||||
}
|
||||
|
||||
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
|
||||
@@ -303,6 +335,7 @@ class UserLiveSet(u: User) {
|
||||
val metadata: UserLiveData = UserLiveData(u)
|
||||
val zaps: UserLiveData = UserLiveData(u)
|
||||
val badges: UserLiveData = UserLiveData(u)
|
||||
val bookmarks: UserLiveData = UserLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers() ||
|
||||
@@ -312,7 +345,8 @@ class UserLiveSet(u: User) {
|
||||
relayInfo.hasObservers() ||
|
||||
metadata.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
badges.hasObservers()
|
||||
badges.hasObservers() ||
|
||||
bookmarks.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,27 +388,22 @@ class UserMetadata {
|
||||
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
|
||||
.any { it.startsWith(prefix, true) }
|
||||
}
|
||||
|
||||
fun lnAddress(): String? {
|
||||
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.withSign
|
||||
|
||||
object BlurHashDecoder {
|
||||
|
||||
// cache Math.cos() calculations to improve performance.
|
||||
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
|
||||
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
|
||||
private val cacheCosinesX = HashMap<Int, DoubleArray>()
|
||||
private val cacheCosinesY = HashMap<Int, DoubleArray>()
|
||||
|
||||
/**
|
||||
* Clear calculations stored in memory cache.
|
||||
* The cache is not big, but will increase when many image sizes are used,
|
||||
* if the app needs memory it is recommended to clear it.
|
||||
*/
|
||||
fun clearCache() {
|
||||
cacheCosinesX.clear()
|
||||
cacheCosinesY.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns width/height
|
||||
*/
|
||||
fun aspectRatio(blurHash: String?): Float? {
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return numCompX.toFloat() / numCompY.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a blur hash into a new bitmap.
|
||||
*
|
||||
* @param useCache use in memory cache for the calculated math, reused by images with same size.
|
||||
* if the cache does not exist yet it will be created and populated with new calculations.
|
||||
* By default it is true.
|
||||
*/
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
val maxAcEnc = decode83(blurHash, 1, 2)
|
||||
val maxAc = (maxAcEnc + 1) / 166f
|
||||
val colors = Array(numCompX * numCompY) { i ->
|
||||
if (i == 0) {
|
||||
val colorEnc = decode83(blurHash, 2, 6)
|
||||
decodeDc(colorEnc)
|
||||
} else {
|
||||
val from = 4 + i * 2
|
||||
val colorEnc = decode83(blurHash, from, from + 2)
|
||||
decodeAc(colorEnc, maxAc * punch)
|
||||
}
|
||||
}
|
||||
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
|
||||
}
|
||||
|
||||
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
|
||||
var result = 0
|
||||
for (i in from until to) {
|
||||
val index = charMap[str[i]] ?: -1
|
||||
if (index != -1) {
|
||||
result = result * 83 + index
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun decodeDc(colorEnc: Int): FloatArray {
|
||||
val r = colorEnc shr 16
|
||||
val g = (colorEnc shr 8) and 255
|
||||
val b = colorEnc and 255
|
||||
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
|
||||
}
|
||||
|
||||
private fun srgbToLinear(colorEnc: Int): Float {
|
||||
val v = colorEnc / 255f
|
||||
return if (v <= 0.04045f) {
|
||||
(v / 12.92f)
|
||||
} else {
|
||||
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
|
||||
val r = value / (19 * 19)
|
||||
val g = (value / 19) % 19
|
||||
val b = value % 19
|
||||
return floatArrayOf(
|
||||
signedPow2((r - 9) / 9.0f) * maxAc,
|
||||
signedPow2((g - 9) / 9.0f) * maxAc,
|
||||
signedPow2((b - 9) / 9.0f) * maxAc
|
||||
)
|
||||
}
|
||||
|
||||
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
|
||||
|
||||
private fun composeBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
numCompX: Int,
|
||||
numCompY: Int,
|
||||
colors: Array<FloatArray>,
|
||||
useCache: Boolean
|
||||
): Bitmap {
|
||||
// use an array for better performance when writing pixel colors
|
||||
val imageArray = IntArray(width * height)
|
||||
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
|
||||
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
|
||||
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
|
||||
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
var r = 0f
|
||||
var g = 0f
|
||||
var b = 0f
|
||||
for (j in 0 until numCompY) {
|
||||
for (i in 0 until numCompX) {
|
||||
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
|
||||
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
|
||||
val basis = (cosX * cosY).toFloat()
|
||||
val color = colors[j * numCompX + i]
|
||||
r += color[0] * basis
|
||||
g += color[1] * basis
|
||||
b += color[2] * basis
|
||||
}
|
||||
}
|
||||
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(height * numCompY).also {
|
||||
cacheCosinesY[height * numCompY] = it
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
cacheCosinesY[height * numCompY]!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(width * numCompX).also {
|
||||
cacheCosinesX[width * numCompX] = it
|
||||
}
|
||||
}
|
||||
else -> cacheCosinesX[width * numCompX]!!
|
||||
}
|
||||
|
||||
private fun DoubleArray.getCos(
|
||||
calculate: Boolean,
|
||||
x: Int,
|
||||
numComp: Int,
|
||||
y: Int,
|
||||
size: Int
|
||||
): Double {
|
||||
if (calculate) {
|
||||
this[x + numComp * y] = cos(Math.PI * y * x / size)
|
||||
}
|
||||
return this[x + numComp * y]
|
||||
}
|
||||
|
||||
private fun linearToSrgb(value: Float): Int {
|
||||
val v = value.coerceIn(0f, 1f)
|
||||
return if (v <= 0.0031308f) {
|
||||
(v * 12.92f * 255f + 0.5f).toInt()
|
||||
} else {
|
||||
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private val charMap = listOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
|
||||
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
|
||||
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
|
||||
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
|
||||
)
|
||||
.mapIndexed { i, c -> c to i }
|
||||
.toMap()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class BlurHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val encodedHash = data.toString().removePrefix("bluehash:")
|
||||
val hash = URLDecoder.decode(encodedHash, "utf-8")
|
||||
|
||||
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
|
||||
|
||||
val preferredWidth = 100
|
||||
|
||||
val bitmap = BlurHashDecoder.decode(
|
||||
hash,
|
||||
preferredWidth,
|
||||
(preferredWidth * (1 / aspectRatio)).roundToInt()
|
||||
)
|
||||
|
||||
if (bitmap == null) {
|
||||
throw Exception("Unable to convert Bluehash $hash")
|
||||
}
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return BlurHashFetcher(options, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object BlurHashRequester {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("bluehash:$encodedMessage")
|
||||
.fetcherFactory(BlurHashFetcher.Factory)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
val url: String,
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val description: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
description: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
try {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(data).toHexKey()
|
||||
val size = data.size
|
||||
|
||||
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
|
||||
val opt = BitmapFactory.Options()
|
||||
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
|
||||
|
||||
val intArray = IntArray(mBitmap.width * mBitmap.height)
|
||||
mBitmap.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
mBitmap.width,
|
||||
0,
|
||||
0,
|
||||
mBitmap.width,
|
||||
mBitmap.height
|
||||
)
|
||||
|
||||
val dim = "${mBitmap.width}x${mBitmap.height}"
|
||||
|
||||
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
|
||||
} else if (aspectRatio < 1) {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
|
||||
} else {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
|
||||
}
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -43,7 +44,7 @@ class Nip05Verifier {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
|
||||
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
|
||||
|
||||
// Rename to the corect nip number when ready.
|
||||
object Nip47 {
|
||||
fun parse(uri: String): Nip47URI {
|
||||
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
|
||||
|
||||
val url = Uri.parse(uri)
|
||||
|
||||
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
|
||||
throw IllegalArgumentException("Not a Wallet Connect QR Code")
|
||||
}
|
||||
|
||||
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
|
||||
|
||||
val pubkeyHex = try {
|
||||
decodePublicKey(pubkey).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
|
||||
}
|
||||
|
||||
val relay = url.getQueryParameter("relay")
|
||||
val secret = url.getQueryParameter("secret")
|
||||
|
||||
return Nip47URI(pubkeyHex, relay, secret)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
@@ -11,16 +13,21 @@ 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.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -31,7 +38,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountMetadataFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -42,7 +49,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -51,34 +58,51 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountBookmarkListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapPaymentResponseEvent.kind,
|
||||
ChannelMessageEvent.kind,
|
||||
BadgeAwardEvent.kind
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 200
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
val accountChannel = requestNewChannel()
|
||||
val accountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
// gets everthing about the user logged in
|
||||
@@ -87,7 +111,23 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter()
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
val event = account.createAuthEvent(relay, challenge)
|
||||
|
||||
if (event != null) {
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.service.model.ChannelMessageEvent
|
||||
@@ -8,13 +9,39 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
var account: Account? = null
|
||||
var channel: Channel? = null
|
||||
|
||||
fun loadMessagesBetween(channelId: String) {
|
||||
fun loadMessagesBetween(account: Account, channelId: String) {
|
||||
this.account = account
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
account = null
|
||||
channel = null
|
||||
}
|
||||
|
||||
fun createMessagesByMeToChannelFilter(): TypedFilter? {
|
||||
val myAccount = account ?: return null
|
||||
|
||||
if (channel != null) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
authors = listOf(myAccount.userProfile().pubkeyHex),
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel != null) {
|
||||
return TypedFilter(
|
||||
@@ -32,6 +59,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
val messagesChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
|
||||
messagesChannel.typedFilters = listOfNotNull(
|
||||
createMessagesToChannelFilter(),
|
||||
createMessagesByMeToChannelFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -12,11 +14,14 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createMessagesToMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -24,7 +29,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -32,22 +38,24 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList()
|
||||
ids = account.followingChannels.toList(),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
@@ -64,13 +72,16 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 100 // Remember to consider spam that is being removed from the UI
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList,
|
||||
limit = 25 // Remember to consider spam that is being removed from the UI
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val chatroomListChannel = requestNewChannel()
|
||||
val chatroomListChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val list = listOf(
|
||||
|
||||
@@ -2,9 +2,11 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
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.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
@@ -26,16 +28,14 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.Subscription
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
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 java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.Error
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
@@ -67,6 +67,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
is BadgeAwardEvent -> LocalCache.consume(event)
|
||||
is BadgeDefinitionEvent -> LocalCache.consume(event)
|
||||
is BadgeProfilesEvent -> LocalCache.consume(event)
|
||||
is BookmarkListEvent -> LocalCache.consume(event)
|
||||
is ChannelCreateEvent -> LocalCache.consume(event)
|
||||
is ChannelHideMessageEvent -> LocalCache.consume(event)
|
||||
is ChannelMessageEvent -> LocalCache.consume(event, relay)
|
||||
@@ -75,11 +76,17 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
|
||||
is FileHeaderEvent -> LocalCache.consume(event, relay)
|
||||
is FileStorageEvent -> LocalCache.consume(event, relay)
|
||||
is FileStorageHeaderEvent -> LocalCache.consume(event, relay)
|
||||
is HighlightEvent -> LocalCache.consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is LnZapRequestEvent -> LocalCache.consume(event)
|
||||
is LnZapPaymentRequestEvent -> LocalCache.consume(event)
|
||||
is LnZapPaymentResponseEvent -> LocalCache.consume(event)
|
||||
is LongTextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
@@ -91,6 +98,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is PollNoteEvent -> LocalCache.consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
@@ -115,18 +123,27 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(Date().time / 1000)
|
||||
subscriptions[channel]?.updateEOSE(Date().time / 1000, relay.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
auth(relay, challenge)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
stop()
|
||||
Client.unsubscribe(clientListener)
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Start")
|
||||
resetFilters()
|
||||
@@ -140,7 +157,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription {
|
||||
fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription {
|
||||
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE)
|
||||
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
|
||||
return newSubscription
|
||||
@@ -152,24 +169,16 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
|
||||
fun invalidateFilters() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(200)
|
||||
resetFiltersSuspend()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
@@ -219,4 +228,5 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -11,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
private var hashtagToWatch: String? = null
|
||||
|
||||
fun createLoadHashtagFilter(): TypedFilter? {
|
||||
val hashToLoad = hashtagToWatch ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
tags = mapOf(
|
||||
"t" to listOf(
|
||||
hashToLoad,
|
||||
hashToLoad.lowercase(),
|
||||
hashToLoad.uppercase(),
|
||||
hashToLoad.capitalize()
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadHashtagChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadHashtag(tag: String?) {
|
||||
hashtagToWatch = tag
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -15,6 +18,8 @@ import kotlinx.coroutines.launch
|
||||
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
invalidateFilters()
|
||||
}
|
||||
@@ -51,16 +56,39 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 400
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel()
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.followingTagSet()
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
|
||||
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
class NostrLnZapPaymentResponseDataSource(
|
||||
private var fromServiceHex: String,
|
||||
private var toUserHex: String,
|
||||
private var replyingToHex: String
|
||||
) : NostrDataSource("LnZapPaymentResponseFeed") {
|
||||
|
||||
val feedTypes = setOf(FeedType.WALLET_CONNECT)
|
||||
|
||||
private fun createWalletConnectServiceWatcher(): TypedFilter {
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = feedTypes,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapPaymentResponseEvent.kind),
|
||||
authors = listOf(fromServiceHex),
|
||||
tags = mapOf(
|
||||
"e" to listOf(replyingToHex),
|
||||
"p" to listOf(toUserHex)
|
||||
),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val channel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val wc = createWalletConnectServiceWatcher()
|
||||
|
||||
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
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.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -18,7 +14,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
val mySearchString = searchString
|
||||
if (mySearchString == null) {
|
||||
if (mySearchString.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -35,27 +31,27 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
null
|
||||
}
|
||||
|
||||
if (hexToWatch == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
return listOfNotNull(
|
||||
hexToWatch?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
)
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
},
|
||||
hexToWatch?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
)
|
||||
)
|
||||
),
|
||||
},
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
@@ -63,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -40,7 +41,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = interestedEvents.toList()
|
||||
|
||||
@@ -2,23 +2,11 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
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.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import java.util.Date
|
||||
|
||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
@@ -31,21 +19,17 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -62,15 +46,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
@@ -88,14 +67,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return reactionsToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.map {
|
||||
return reactionsToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
@@ -104,7 +78,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
RepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapRequestEvent.kind
|
||||
LnZapRequestEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -133,11 +109,17 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
|
||||
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||
PrivateDmEvent.kind,
|
||||
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
|
||||
HighlightEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
@@ -145,10 +127,16 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel { time ->
|
||||
val singleEventChannel = requestNewChannel { time, relayUrl ->
|
||||
eventsToWatch.forEach {
|
||||
it.lastReactionsDownloadTime = time
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
@@ -160,7 +148,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createTagToAddressFilter()
|
||||
|
||||
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null }
|
||||
singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: Note) {
|
||||
|
||||
@@ -3,7 +3,8 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -15,7 +16,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -30,17 +31,26 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
since = it.latestReportTime
|
||||
since = it.latestEOSEs
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val userChannel = requestNewChannel() {
|
||||
val userChannel = requestNewChannel() { time, relayUrl ->
|
||||
usersToWatch.forEach {
|
||||
val eose = it.latestEOSEs[relayUrl]
|
||||
if (eose == null) {
|
||||
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -18,14 +18,14 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
.ifEmpty { null } ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = eventsToLoad.map { it.substring(0, 8) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadEventsChannel = requestNewChannel() {
|
||||
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()
|
||||
|
||||
@@ -2,14 +2,8 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
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.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -28,7 +22,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -39,9 +33,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createUserPostsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
@@ -50,7 +44,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createUserReceivedZapsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
@@ -60,7 +54,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createFollowFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -71,7 +65,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createFollowersFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
@@ -81,7 +75,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createAcceptedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -90,9 +84,20 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createBookmarksFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createReceivedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeAwardEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
@@ -111,7 +116,8 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter(),
|
||||
createAcceptedAwardsFilter(),
|
||||
createReceivedAwardsFilter()
|
||||
createReceivedAwardsFilter(),
|
||||
createBookmarksFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -12,6 +13,7 @@ import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.math.BigDecimal
|
||||
import java.net.URLEncoder
|
||||
|
||||
class LightningAddressResolver {
|
||||
@@ -52,7 +54,7 @@ class LightningAddressResolver {
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
@@ -95,7 +97,7 @@ class LightningAddressResolver {
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
@@ -128,12 +130,14 @@ class LightningAddressResolver {
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = { lnAddressJson ->
|
||||
onProgress(0.4f)
|
||||
|
||||
val lnurlp = try {
|
||||
mapper.readTree(lnAddressJson)
|
||||
} catch (t: Throwable) {
|
||||
@@ -156,6 +160,8 @@ class LightningAddressResolver {
|
||||
message,
|
||||
if (allowsNostr) nostrRequest else null,
|
||||
onSuccess = {
|
||||
onProgress(0.6f)
|
||||
|
||||
val lnInvoice = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
@@ -164,7 +170,14 @@ class LightningAddressResolver {
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.let { pr ->
|
||||
onSuccess(pr)
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
|
||||
onProgress(0.7f)
|
||||
onSuccess(pr)
|
||||
} else {
|
||||
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
|
||||
}
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
},
|
||||
onError = onError
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object LnWithdrawalUtil {
|
||||
private val withdrawalPattern = Pattern.compile(
|
||||
"lnurl.*",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
)
|
||||
|
||||
/**
|
||||
* Finds LN withdrawal in the provided input string and returns it.
|
||||
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
|
||||
* It will only return the first withdrawal found in the input.
|
||||
*
|
||||
* @return the invoice if it was found. null for null input or if no invoice is found
|
||||
*/
|
||||
fun findWithdrawal(input: String?): String? {
|
||||
if (input == null) {
|
||||
return null
|
||||
}
|
||||
val matcher = withdrawalPattern.matcher(input)
|
||||
return if (matcher.find()) {
|
||||
matcher.group()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.tagSearch
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
|
||||
|
||||
open class BaseTextNoteEvent(
|
||||
id: HexKey,
|
||||
@@ -13,13 +15,48 @@ open class BaseTextNoteEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun mentions() = taggedUsers()
|
||||
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
private var citedUsersCache: Set<HexKey>? = null
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
fun citedUsers(): Set<HexKey> {
|
||||
citedUsersCache?.let { return it }
|
||||
|
||||
val matcher = tagSearch.matcher(content)
|
||||
val returningList = mutableSetOf<String>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { tags[it.toInt()] }
|
||||
if (tag != null && tag.size > 1 && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
val matcher2 = nip19regex.matcher(content)
|
||||
while (matcher2.find()) {
|
||||
val uriScheme = matcher2.group(1) // nostr:
|
||||
val type = matcher2.group(2) // npub1
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
try {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
citedUsersCache = returningList
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun findCitations(): Set<String> {
|
||||
@@ -29,15 +66,40 @@ open class BaseTextNoteEvent(
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { tags[it.toInt()] }
|
||||
if (tag != null && tag[0] == "e") {
|
||||
if (tag != null && tag.size > 1 && tag[0] == "e") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
if (tag != null && tag[0] == "a") {
|
||||
if (tag != null && tag.size > 1 && tag[0] == "a") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
val matcher2 = nip19regex.matcher(content)
|
||||
while (matcher2.find()) {
|
||||
val uriScheme = matcher2.group(1) // nostr:
|
||||
val type = matcher2.group(2) // npub1
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
try {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "e") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
if (tag != null && tag[0] == "a") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return citations
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class BookmarkListEvent(
|
||||
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(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
private var privateTagsCache: List<List<String>>? = null
|
||||
|
||||
fun privateTags(privKey: ByteArray): List<List<String>>? {
|
||||
if (privateTagsCache != null) {
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
privateTagsCache = try {
|
||||
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
|
||||
} catch (e: Throwable) {
|
||||
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
|
||||
null
|
||||
}
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 30001
|
||||
|
||||
fun create(
|
||||
name: String = "",
|
||||
|
||||
events: List<String>? = null,
|
||||
users: List<String>? = null,
|
||||
addresses: List<ATag>? = null,
|
||||
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): BookmarkListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
|
||||
val privTags = mutableListOf<List<String>>()
|
||||
privEvents?.forEach {
|
||||
privTags.add(listOf("e", it))
|
||||
}
|
||||
privUsers?.forEach {
|
||||
privTags.add(listOf("p", it))
|
||||
}
|
||||
privAddresses?.forEach {
|
||||
privTags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val msg = gson.toJson(privTags)
|
||||
|
||||
val content = Utils.encrypt(
|
||||
msg,
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
events?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
users?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return BookmarkListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,15 @@ class ChannelMessageEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
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) }
|
||||
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.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 {
|
||||
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf(
|
||||
@@ -33,6 +32,9 @@ class ChannelMessageEvent(
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -23,16 +23,26 @@ class ContactListEvent(
|
||||
val verifiedFollowKeySet: Set<HexKey> by lazy {
|
||||
tags.filter { it[0] == "p" }.mapNotNull {
|
||||
it.getOrNull(1)?.let { unverifiedHex: String ->
|
||||
decodePublicKey(unverifiedHex).toHexKey()
|
||||
try {
|
||||
decodePublicKey(unverifiedHex).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
val verifiedFollowTagSet: Set<String> by lazy {
|
||||
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
|
||||
}
|
||||
|
||||
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
|
||||
verifiedFollowKeySet + pubKey
|
||||
}
|
||||
|
||||
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
|
||||
try {
|
||||
@@ -43,6 +53,10 @@ class ContactListEvent(
|
||||
}
|
||||
}
|
||||
|
||||
fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull {
|
||||
it.getOrNull(2)
|
||||
}
|
||||
|
||||
fun relays(): Map<String, ReadWrite>? = try {
|
||||
if (content.isNotEmpty()) {
|
||||
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
|
||||
@@ -57,7 +71,7 @@ class ContactListEvent(
|
||||
companion object {
|
||||
const val kind = 3
|
||||
|
||||
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
|
||||
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
|
||||
val content = if (relayUse != null) {
|
||||
gson.toJson(relayUse)
|
||||
} else {
|
||||
@@ -70,7 +84,10 @@ class ContactListEvent(
|
||||
} else {
|
||||
listOf("p", it.pubKeyHex)
|
||||
}
|
||||
} + followTags.map {
|
||||
listOf("t", it)
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
|
||||
@@ -9,6 +9,7 @@ import fr.acinq.secp256k1.Secp256k1
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
import java.lang.reflect.Type
|
||||
import java.math.BigDecimal
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
|
||||
@@ -37,9 +38,56 @@ open class Event(
|
||||
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
|
||||
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
|
||||
|
||||
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
val aTagValue = it[1]
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
ATag.parse(aTagValue, relay)
|
||||
}
|
||||
|
||||
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
|
||||
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
|
||||
|
||||
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
|
||||
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
|
||||
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
|
||||
|
||||
override fun getPoWRank(): Int {
|
||||
var rank = 0
|
||||
for (i in 0..id.length) {
|
||||
if (id[i] == '0') {
|
||||
rank += 4
|
||||
} else if (id[i] in '4'..'7') {
|
||||
rank += 1
|
||||
break
|
||||
} else if (id[i] in '2'..'3') {
|
||||
rank += 2
|
||||
break
|
||||
} else if (id[i] == '1') {
|
||||
rank += 3
|
||||
break
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
override fun getReward(): BigDecimal? {
|
||||
return try {
|
||||
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||
@@ -159,6 +207,7 @@ open class Event(
|
||||
.registerTypeAdapter(Event::class.java, EventDeserializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
|
||||
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
@@ -169,7 +218,7 @@ open class Event(
|
||||
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)
|
||||
|
||||
BookmarkListEvent.kind -> BookmarkListEvent(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)
|
||||
@@ -178,10 +227,17 @@ open class Event(
|
||||
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.kind -> PollNoteEvent(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)
|
||||
@@ -200,7 +256,15 @@ open class Event(
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import java.math.BigDecimal
|
||||
|
||||
interface EventInterface {
|
||||
fun id(): HexKey
|
||||
@@ -23,5 +24,15 @@ interface EventInterface {
|
||||
|
||||
fun hasValidSignature(): Boolean
|
||||
|
||||
fun isTaggedUser(loggedInUser: String): Boolean
|
||||
fun isTaggedUser(idHex: String): Boolean
|
||||
|
||||
fun isTaggedHash(hashtag: String): Boolean
|
||||
fun isTaggedHashes(hashtags: Set<String>): Boolean
|
||||
fun firstIsTaggedHashes(hashtags: Set<String>): String?
|
||||
fun hashtags(): List<String>
|
||||
|
||||
fun getReward(): BigDecimal?
|
||||
fun getPoWRank(): Int
|
||||
|
||||
fun zapAddress(): String?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class FileHeaderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
|
||||
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
|
||||
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
|
||||
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 1063
|
||||
|
||||
private const val URL = "url"
|
||||
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||
private const val MIME_TYPE = "m"
|
||||
private const val FILE_SIZE = "size"
|
||||
private const val DIMENSION = "dim"
|
||||
private const val HASH = "x"
|
||||
private const val MAGNET_URI = "magnet"
|
||||
private const val TORRENT_INFOHASH = "i"
|
||||
private const val BLUR_HASH = "blurhash"
|
||||
|
||||
fun create(
|
||||
url: String,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
encryptionKey: AESGCM? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): FileHeaderEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(URL, url),
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||
)
|
||||
|
||||
val content = description ?: ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AESGCM(val key: String, val nonce: String)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Base64
|
||||
import java.util.Date
|
||||
|
||||
class FileStorageEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
|
||||
fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) }
|
||||
|
||||
fun decode(): ByteArray? {
|
||||
return try {
|
||||
Base64.getDecoder().decode(content)
|
||||
} catch (e: Exception) {
|
||||
Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 1064
|
||||
|
||||
private const val TYPE = "type"
|
||||
private const val DECRYPT = "decrypt"
|
||||
|
||||
fun encode(bytes: ByteArray): String {
|
||||
return Base64.getEncoder().encodeToString(bytes)
|
||||
}
|
||||
|
||||
fun create(
|
||||
mimeType: String,
|
||||
data: ByteArray,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): FileStorageEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(TYPE, mimeType)
|
||||
)
|
||||
|
||||
val content = encode(data)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class FileStorageHeaderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
|
||||
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
|
||||
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 1065
|
||||
|
||||
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||
private const val MIME_TYPE = "m"
|
||||
private const val FILE_SIZE = "size"
|
||||
private const val DIMENSION = "dim"
|
||||
private const val HASH = "x"
|
||||
private const val MAGNET_URI = "magnet"
|
||||
private const val TORRENT_INFOHASH = "i"
|
||||
private const val BLUR_HASH = "blurhash"
|
||||
|
||||
fun create(
|
||||
storageEvent: FileStorageEvent,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
encryptionKey: AESGCM? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): FileStorageHeaderEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf("e", storageEvent.id),
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||
)
|
||||
|
||||
val content = description ?: ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class HighlightEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun inUrl() = taggedUrls().firstOrNull()
|
||||
fun author() = taggedUsers().firstOrNull()
|
||||
fun quote() = content
|
||||
|
||||
companion object {
|
||||
const val kind = 9802
|
||||
|
||||
fun create(
|
||||
msg: String,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PollNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,74 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LnZapEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
override fun zappedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun zappedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun taggedAddresses(): List<ATag> = tags
|
||||
.filter { it.firstOrNull() == "a" }
|
||||
.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
override fun amount(): BigDecimal? {
|
||||
return amount
|
||||
}
|
||||
|
||||
// Keeps this as a field because it's a heavier function used everywhere.
|
||||
val amount by lazy {
|
||||
try {
|
||||
lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun containedPost(): Event? = try {
|
||||
description()?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
|
||||
null
|
||||
}
|
||||
|
||||
private fun lnInvoice(): String? = tags
|
||||
.filter { it.firstOrNull() == "bolt11" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
|
||||
private fun description(): String? = tags
|
||||
.filter { it.firstOrNull() == "description" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
|
||||
companion object {
|
||||
const val kind = 9735
|
||||
}
|
||||
}
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
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
|
||||
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
// This event is also kept in LocalCache (same object)
|
||||
@Transient val zapRequest: LnZapRequestEvent?
|
||||
|
||||
override fun containedPost(): LnZapRequestEvent? = try {
|
||||
description()?.ifBlank { null }?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
} as? LnZapRequestEvent
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
|
||||
null
|
||||
}
|
||||
|
||||
init {
|
||||
zapRequest = containedPost()
|
||||
}
|
||||
|
||||
override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
override fun zappedPollOption(): Int? = try {
|
||||
zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt()
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "ZappedPollOption failed to parse", e)
|
||||
null
|
||||
}
|
||||
|
||||
override fun zappedRequestAuthor(): String? = zapRequest?.pubKey()
|
||||
|
||||
override fun amount() = amount
|
||||
|
||||
// Keeps this as a field because it's a heavier function used everywhere.
|
||||
val amount by lazy {
|
||||
try {
|
||||
lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
override fun content(): String {
|
||||
return content
|
||||
}
|
||||
|
||||
fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||
|
||||
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 9735
|
||||
}
|
||||
|
||||
enum class ZapType() {
|
||||
PUBLIC,
|
||||
PRIVATE,
|
||||
ANONYMOUS,
|
||||
NONZAP
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ interface LnZapEventInterface : EventInterface {
|
||||
|
||||
fun zappedPost(): List<String>
|
||||
|
||||
fun zappedPollOption(): Int?
|
||||
|
||||
fun zappedAuthor(): List<String>
|
||||
|
||||
fun zappedRequestAuthor(): String?
|
||||
|
||||
fun taggedAddresses(): List<ATag>
|
||||
|
||||
fun amount(): BigDecimal?
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class LnZapPaymentRequestEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun lnInvoice(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 23194
|
||||
|
||||
fun create(
|
||||
lnInvoice: String,
|
||||
walletServicePubkey: String,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapPaymentRequestEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice))
|
||||
|
||||
val content = Utils.encrypt(
|
||||
serializedRequest,
|
||||
privateKey,
|
||||
walletServicePubkey.toByteArray()
|
||||
)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("p", walletServicePubkey))
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapPaymentRequestEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REQUEST OBJECTS
|
||||
|
||||
abstract class Request(val method: String, val params: Params)
|
||||
abstract class Params
|
||||
|
||||
// PayInvoice Call
|
||||
|
||||
class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) {
|
||||
class PayInvoiceParams(val invoice: String) : Params()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class LnZapPaymentResponseEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
|
||||
|
||||
val retVal = Utils.decrypt(content, sharedSecret)
|
||||
|
||||
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
|
||||
retVal.substring(16)
|
||||
} else {
|
||||
retVal
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
|
||||
if (content.isNotEmpty()) {
|
||||
val decrypted = decrypt(privKey, pubKey)
|
||||
gson.fromJson(decrypted, Response::class.java)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 23195
|
||||
}
|
||||
}
|
||||
|
||||
// RESPONSE OBJECTS
|
||||
abstract class Response(
|
||||
@SerializedName("result_type")
|
||||
val resultType: String
|
||||
)
|
||||
|
||||
// PayInvoice Call
|
||||
|
||||
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
|
||||
Response("pay_invoice") {
|
||||
class PayInvoiceResultParams(val preimage: String)
|
||||
}
|
||||
|
||||
class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
|
||||
Response("pay_invoice") {
|
||||
class PayInvoiceErrorParams(val code: ErrorType?, val message: String?)
|
||||
|
||||
enum class ErrorType {
|
||||
@SerializedName(value = "rate_limited", alternate = ["RATE_LIMITED"])
|
||||
RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds.
|
||||
@SerializedName(value = "not_implemented", alternate = ["NOT_IMPLEMENTED"])
|
||||
NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented.
|
||||
@SerializedName(value = "insufficient_balance", alternate = ["INSUFFICIENT_BALANCE"])
|
||||
INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount.
|
||||
@SerializedName(value = "quota_exceeded", alternate = ["QUOTA_EXCEEDED"])
|
||||
QUOTA_EXCEEDED, // The wallet has exceeded its spending quota.
|
||||
@SerializedName(value = "restricted", alternate = ["RESTRICTED"])
|
||||
RESTRICTED, // This public key is not allowed to do this operation.
|
||||
@SerializedName(value = "unauthorized", alternate = ["UNAUTHORIZED"])
|
||||
UNAUTHORIZED, // This public key has no wallet connected.
|
||||
@SerializedName(value = "internal", alternate = ["INTERNAL"])
|
||||
INTERNAL, // An internal error.
|
||||
@SerializedName(value = "other", alternate = ["OTHER"])
|
||||
OTHER // Other error.
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseDeserializer :
|
||||
JsonDeserializer<Response?> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type,
|
||||
context: JsonDeserializationContext
|
||||
): Response? {
|
||||
val jsonObject = json.asJsonObject
|
||||
val resultType = jsonObject.get("result_type")?.asString
|
||||
|
||||
if (resultType == "pay_invoice") {
|
||||
val result = jsonObject.get("result")?.asJsonObject
|
||||
val error = jsonObject.get("error")?.asJsonObject
|
||||
if (result != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (error != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
|
||||
}
|
||||
} else {
|
||||
// tries to guess
|
||||
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
}
|
||||
}
|
||||
@@ -1,101 +1,210 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
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 {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
fun create(
|
||||
originalNote: EventInterface,
|
||||
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"
|
||||
]
|
||||
]
|
||||
}
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.Utils
|
||||
import java.nio.charset.Charset
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
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.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
fun create(
|
||||
originalNote: EventInterface,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
var 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()))
|
||||
}
|
||||
if (pollOption != null && pollOption >= 0) {
|
||||
tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString()))
|
||||
}
|
||||
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
|
||||
tags = tags + listOf(listOf("anon", ""))
|
||||
privkey = Utils.privkeyCreate()
|
||||
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = "" // make sure public content is empty, as the content is encrypted
|
||||
privkey = encryptionPrivateKey // sign event with generated privkey
|
||||
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey() // updated event with according pubkey
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privkey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(
|
||||
userHex: String,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("p", userHex),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
|
||||
privkey = Utils.privkeyCreate()
|
||||
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
|
||||
tags = tags + listOf(listOf("anon", ""))
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = ""
|
||||
privkey = encryptionPrivateKey
|
||||
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey()
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privkey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
|
||||
var str = privkey + id + createdAt.toString()
|
||||
var strbyte = str.toByteArray(Charset.forName("utf-8"))
|
||||
return sha256.digest(strbyte)
|
||||
}
|
||||
|
||||
fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
val iv = ByteArray(16)
|
||||
SecureRandom().nextBytes(iv)
|
||||
|
||||
val keySpec = SecretKeySpec(sharedSecret, "AES")
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
|
||||
var utf8message = msg.toByteArray(Charset.forName("utf-8"))
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
|
||||
val encryptedMsg = cipher.doFinal(utf8message)
|
||||
|
||||
val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32)
|
||||
val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32)
|
||||
|
||||
return encryptedMsgBech32 + "_" + ivBech32
|
||||
}
|
||||
|
||||
fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
|
||||
throw IllegalArgumentException("Invalid shared secret size")
|
||||
}
|
||||
val parts = msg.split("_")
|
||||
if (parts.size != 2) {
|
||||
throw IllegalArgumentException("Invalid message format")
|
||||
}
|
||||
val iv = parts[1].run { Bech32.decode(this).second }
|
||||
val encryptedMsg = parts.first().run { Bech32.decode(this).second }
|
||||
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0)))
|
||||
|
||||
try {
|
||||
val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
|
||||
return String(decryptedMsgBytes)
|
||||
} catch (ex: BadPaddingException) {
|
||||
throw IllegalArgumentException("Bad padding: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
|
||||
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
|
||||
if (anonTag != null) {
|
||||
val encnote = anonTag[1]
|
||||
if (encnote.isNotBlank()) {
|
||||
try {
|
||||
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
|
||||
val decryptedEvent = fromJson(note)
|
||||
if (decryptedEvent.kind == 9733) {
|
||||
return decryptedEvent
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
{
|
||||
"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"
|
||||
],
|
||||
[
|
||||
"poll_option", "n"
|
||||
]
|
||||
],
|
||||
"ots": <base64-encoded OTS file data> // TODO
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class MuteListEvent(
|
||||
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(), null)
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
private var privateTagsCache: List<List<String>>? = null
|
||||
|
||||
fun privateTags(privKey: ByteArray): List<List<String>>? {
|
||||
if (privateTagsCache != null) {
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
privateTagsCache = try {
|
||||
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
|
||||
} catch (e: Throwable) {
|
||||
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
|
||||
null
|
||||
}
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 10000
|
||||
|
||||
fun create(
|
||||
events: List<String>? = null,
|
||||
users: List<String>? = null,
|
||||
addresses: List<ATag>? = null,
|
||||
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): MuteListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
|
||||
val privTags = mutableListOf<List<String>>()
|
||||
privEvents?.forEach {
|
||||
privTags.add(listOf("e", it))
|
||||
}
|
||||
privUsers?.forEach {
|
||||
privTags.add(listOf("p", it))
|
||||
}
|
||||
privAddresses?.forEach {
|
||||
privTags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val msg = gson.toJson(privTags)
|
||||
|
||||
val content = Utils.encrypt(
|
||||
msg,
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
events?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
users?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return MuteListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
const val POLL_OPTION = "poll_option"
|
||||
const val VALUE_MAXIMUM = "value_maximum"
|
||||
const val VALUE_MINIMUM = "value_minimum"
|
||||
const val CONSENSUS_THRESHOLD = "consensus_threshold"
|
||||
const val CLOSED_AT = "closed_at"
|
||||
|
||||
class PollNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
// ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun pollOptions() =
|
||||
tags.filter { it.size > 2 && it[0] == POLL_OPTION }
|
||||
.associate { it[1].toInt() to it[2] }
|
||||
|
||||
fun getTagInt(property: String): Int? {
|
||||
val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
|
||||
|
||||
return if (number.isNullOrBlank() || number == "null") {
|
||||
null
|
||||
} else {
|
||||
number.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 6969
|
||||
|
||||
fun create(
|
||||
msg: String,
|
||||
replyTos: List<String>?,
|
||||
mentions: List<String>?,
|
||||
addresses: List<ATag>?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
pollOptions: Map<Int, String>,
|
||||
valueMaximum: Int?,
|
||||
valueMinimum: Int?,
|
||||
consensusThreshold: Int?,
|
||||
closedAt: Int?,
|
||||
zapReceiver: String?
|
||||
): PollNoteEvent {
|
||||
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()))
|
||||
}
|
||||
pollOptions.forEach { poll_op ->
|
||||
tags.add(listOf(POLL_OPTION, poll_op.key.toString(), poll_op.value))
|
||||
}
|
||||
tags.add(listOf(VALUE_MAXIMUM, valueMaximum.toString()))
|
||||
tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
|
||||
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
|
||||
tags.add(listOf(CLOSED_AT, closedAt.toString()))
|
||||
|
||||
if (zapReceiver != null) {
|
||||
tags.add(listOf("zap", zapReceiver))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
{
|
||||
"id": <32-bytes lowercase hex-encoded sha256 of the serialized event data>
|
||||
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
|
||||
"created_at": <unix timestamp in seconds>,
|
||||
"kind": 6969,
|
||||
"tags": [
|
||||
["e", <32-bytes hex of the id of the poll event>, <primary poll host relay URL>],
|
||||
["p", <32-bytes hex of the key>, <primary poll host relay URL>],
|
||||
["poll_option", "0", "poll option 0 description string"],
|
||||
["poll_option", "1", "poll option 1 description string"],
|
||||
["poll_option", "n", "poll option <n> description string"],
|
||||
["value_maximum", "maximum satoshi value for inclusion in tally"],
|
||||
["value_minimum", "minimum satoshi value for inclusion in tally"],
|
||||
["consensus_threshold", "required percentage to attain consensus <0..100>"],
|
||||
["closed_at", "unix timestamp in seconds"],
|
||||
],
|
||||
"ots": <base64-encoded OTS file data>
|
||||
"content": <primary poll description string>,
|
||||
"sig": <64-bytes hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field>
|
||||
}
|
||||
*/
|
||||
@@ -21,7 +21,7 @@ class PrivateDmEvent(
|
||||
* 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
|
||||
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "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.
|
||||
@@ -29,7 +29,7 @@ class PrivateDmEvent(
|
||||
* 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 replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
@@ -58,6 +58,7 @@ class PrivateDmEvent(
|
||||
msg: String,
|
||||
replyTos: List<String>? = null,
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
publishedRecipientPubKey: ByteArray? = null,
|
||||
@@ -79,6 +80,9 @@ class PrivateDmEvent(
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", 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())
|
||||
|
||||
@@ -14,14 +14,8 @@ class ReactionEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
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 {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
companion object {
|
||||
const val kind = 7
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class RelayAuthEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1)
|
||||
fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 22242
|
||||
|
||||
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("relay", relay),
|
||||
listOf("challenge", challenge)
|
||||
)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class ReportEvent(
|
||||
}
|
||||
|
||||
fun reportedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
|
||||
.filter { it.size > 1 && it[0] == "e" }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
@@ -39,7 +39,7 @@ class ReportEvent(
|
||||
}
|
||||
|
||||
fun reportedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
|
||||
.filter { it.size > 1 && it[0] == "p" }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
@@ -47,19 +47,16 @@ class ReportEvent(
|
||||
)
|
||||
}
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 1984
|
||||
|
||||
fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
fun create(
|
||||
reportedPost: EventInterface,
|
||||
type: ReportType,
|
||||
privateKey: ByteArray,
|
||||
content: String = "",
|
||||
createdAt: Long = Date().time / 1000
|
||||
): ReportEvent {
|
||||
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
|
||||
|
||||
|
||||
@@ -17,12 +17,6 @@ class RepostEvent(
|
||||
|
||||
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 {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
fun containedPost() = try {
|
||||
fromJson(content, Client.lenient)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@@ -17,7 +20,16 @@ class TextNoteEvent(
|
||||
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 {
|
||||
fun create(
|
||||
msg: String,
|
||||
replyTos: List<String>?,
|
||||
mentions: List<String>?,
|
||||
addresses: List<ATag>?,
|
||||
extraTags: List<String>?,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): TextNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
replyTos?.forEach {
|
||||
@@ -29,9 +41,26 @@ class TextNoteEvent(
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
findHashtags(msg).forEach {
|
||||
tags.add(listOf("t", it))
|
||||
}
|
||||
extraTags?.forEach {
|
||||
tags.add(listOf("t", it))
|
||||
}
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
findURLs(msg).forEach {
|
||||
tags.add(listOf("r", it))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun findURLs(text: String): List<String> {
|
||||
return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl }
|
||||
}
|
||||
|
||||
@@ -1,40 +1,75 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toByteArray
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object Nip19 {
|
||||
enum class Type {
|
||||
USER, NOTE, RELAY, ADDRESS
|
||||
USER, NOTE, EVENT, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
data class Return(val type: Type, val hex: String, val relay: String? = null)
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
data class Return(
|
||||
val type: Type,
|
||||
val hex: String,
|
||||
val relay: String? = null,
|
||||
val author: String? = null,
|
||||
val kind: Long? = null,
|
||||
val additionalChars: String = ""
|
||||
)
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
try {
|
||||
val key = uri?.removePrefix("nostr:") ?: return null
|
||||
if (uri == null) return null
|
||||
|
||||
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)
|
||||
try {
|
||||
val matcher = nip19regex.matcher(uri)
|
||||
if (!matcher.find()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val uriScheme = matcher.group(1) // nostr:
|
||||
val type = matcher.group(2) // npub1
|
||||
val key = matcher.group(3) // bech32
|
||||
val additionalChars = matcher.group(4) // additional chars
|
||||
|
||||
return parseComponents(uriScheme, type, key, additionalChars)
|
||||
} catch (e: Throwable) {
|
||||
println("Issue trying to Decode NIP19 $uri: ${e.message}")
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseComponents(
|
||||
uriScheme: String?,
|
||||
type: String,
|
||||
key: String?,
|
||||
additionalChars: String?
|
||||
): Return? {
|
||||
return try {
|
||||
val bytes = (type + key).bechToBytes()
|
||||
val parsed = when (type.lowercase()) {
|
||||
"npub1" -> npub(bytes)
|
||||
"note1" -> note(bytes)
|
||||
"nprofile1" -> nprofile(bytes)
|
||||
"nevent1" -> nevent(bytes)
|
||||
"nrelay1" -> nrelay(bytes)
|
||||
"naddr1" -> naddr(bytes)
|
||||
else -> null
|
||||
}
|
||||
parsed?.copy(additionalChars = additionalChars ?: "")
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun npub(bytes: ByteArray): Return {
|
||||
return Return(Type.USER, bytes.toHexKey())
|
||||
}
|
||||
@@ -68,7 +103,15 @@ object Nip19 {
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
return Return(Type.USER, hex, relay)
|
||||
val author = tlv.get(Tlv.Type.AUTHOR.id)
|
||||
?.get(0)
|
||||
?.toHexKey()
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
|
||||
return Return(Type.EVENT, hex, relay, author, kind)
|
||||
}
|
||||
|
||||
private fun nrelay(bytes: ByteArray): Return? {
|
||||
@@ -97,8 +140,31 @@ object Nip19 {
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d", relay)
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
|
||||
}
|
||||
|
||||
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
|
||||
val kind = kind?.toByteArray()
|
||||
val author = author?.toByteArray()
|
||||
val idHex = idHex.toByteArray()
|
||||
val relay = relay?.toByteArray(Charsets.UTF_8)
|
||||
|
||||
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
|
||||
|
||||
if (relay != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
|
||||
}
|
||||
|
||||
if (author != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author
|
||||
}
|
||||
|
||||
if (kind != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
|
||||
}
|
||||
|
||||
return Bech32.encodeBytes(hrp = "nevent", fullArray, Bech32.Encoding.Bech32)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
@@ -64,8 +65,52 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
RelayPool.send(signedEvent)
|
||||
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
|
||||
if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
|
||||
|
||||
if (useConnectedRelayIfPresent.isNotEmpty()) {
|
||||
useConnectedRelayIfPresent.forEach {
|
||||
it.send(signedEvent)
|
||||
}
|
||||
} else {
|
||||
/** temporary connection */
|
||||
newSporadicRelay(
|
||||
relay,
|
||||
feedTypes,
|
||||
onConnected = { relay ->
|
||||
relay.send(signedEvent)
|
||||
},
|
||||
onDone = onDone
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
relay.requestAndWatch {
|
||||
allSubscriptions().forEach {
|
||||
relay.sendFilter(requestId = it)
|
||||
}
|
||||
|
||||
onConnected(relay)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(10000) // waits for a reply
|
||||
relay.disconnect()
|
||||
RelayPool.removeRelay(relay)
|
||||
|
||||
if (onDone != null) {
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String) {
|
||||
@@ -115,6 +160,14 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
// 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.onAuth(relay, challenge) }
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
@@ -151,5 +204,7 @@ object Client : RelayPool.Listener {
|
||||
* When an relay saves or rejects a new event.
|
||||
*/
|
||||
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
|
||||
|
||||
open fun onAuth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS)
|
||||
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
val activeTypesSearch = setOf(FeedType.SEARCH)
|
||||
|
||||
@@ -14,16 +15,18 @@ object Constants {
|
||||
}
|
||||
|
||||
val defaultRelays = arrayOf(
|
||||
// Free relays
|
||||
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
|
||||
// Free relays for DMs and Follows
|
||||
RelaySetupInfo("wss://no.str.cr", 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),
|
||||
|
||||
// Chats
|
||||
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats),
|
||||
|
||||
// Less Reliable
|
||||
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
class EOSETime(var time: Long) {
|
||||
override fun toString(): String {
|
||||
return time.toString()
|
||||
}
|
||||
}
|
||||
|
||||
class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
|
||||
fun addOrUpdate(relayUrl: String, time: Long) {
|
||||
val eose = relayList[relayUrl]
|
||||
if (eose == null) {
|
||||
relayList = relayList + Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EOSEAccount(var users: Map<User, EOSERelayList> = emptyMap()) {
|
||||
fun addOrUpdate(user: User, relayUrl: String, time: Long) {
|
||||
val relayList = users[user]
|
||||
if (relayList == null) {
|
||||
users = users + mapOf(user to EOSERelayList(mapOf(relayUrl to EOSETime(time))))
|
||||
} else {
|
||||
relayList.addOrUpdate(relayUrl, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,26 +4,18 @@ 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 since: Map<String, EOSETime>? = null,
|
||||
val until: Long? = null,
|
||||
val limit: Int? = null,
|
||||
val search: String? = null
|
||||
) : Filter, Serializable {
|
||||
fun toJson(): String {
|
||||
) {
|
||||
fun toJson(forRelay: String? = null): String {
|
||||
val jsonObject = JsonObject()
|
||||
ids?.run {
|
||||
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
|
||||
@@ -40,7 +32,20 @@ class JsonFilter(
|
||||
}
|
||||
}
|
||||
since?.run {
|
||||
jsonObject.addProperty("since", since)
|
||||
if (!isEmpty()) {
|
||||
if (forRelay != null) {
|
||||
val relaySince = get(forRelay)
|
||||
if (relaySince != null) {
|
||||
jsonObject.addProperty("since", relaySince.time)
|
||||
}
|
||||
} else {
|
||||
val jsonObjectSince = JsonObject()
|
||||
entries.forEach { sincePairs ->
|
||||
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
|
||||
}
|
||||
jsonObject.add("since", jsonObjectSince)
|
||||
}
|
||||
}
|
||||
}
|
||||
until?.run {
|
||||
jsonObject.addProperty("until", until)
|
||||
@@ -54,90 +59,7 @@ class JsonFilter(
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonElement
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -12,9 +14,11 @@ import okhttp3.WebSocketListener
|
||||
import java.util.Date
|
||||
|
||||
enum class FeedType {
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
|
||||
}
|
||||
|
||||
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
|
||||
class Relay(
|
||||
var url: String,
|
||||
var read: Boolean = true,
|
||||
@@ -30,14 +34,17 @@ class Relay(
|
||||
private var socket: WebSocket? = null
|
||||
private var isReady: Boolean = false
|
||||
|
||||
var eventDownloadCounter = 0
|
||||
var eventDownloadCounterInBytes = 0
|
||||
var eventUploadCounterInBytes = 0
|
||||
|
||||
var spamCounter = 0
|
||||
var eventUploadCounter = 0
|
||||
var errorCounter = 0
|
||||
var ping: Long? = null
|
||||
|
||||
var closingTime = 0L
|
||||
|
||||
var afterEOSE = false
|
||||
|
||||
fun register(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
@@ -52,52 +59,72 @@ class Relay(
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch() {
|
||||
requestAndWatch {
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch(onConnected: (Relay) -> Unit) {
|
||||
if (socket != null) return
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url.trim())
|
||||
.build()
|
||||
val listener = object : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
afterEOSE = false
|
||||
isReady = true
|
||||
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
|
||||
|
||||
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
onConnected(this@Relay)
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
eventDownloadCounterInBytes += text.bytesUsedInMemory()
|
||||
|
||||
try {
|
||||
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
|
||||
val type = msg[0].asString
|
||||
val channel = msg[1].asString
|
||||
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
// Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
eventDownloadCounter++
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
listeners.forEach { it.onEvent(this@Relay, channel, event) }
|
||||
|
||||
// Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
listeners.forEach {
|
||||
it.onEvent(this@Relay, channel, event)
|
||||
if (afterEOSE) {
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
"EOSE" -> listeners.forEach {
|
||||
afterEOSE = true
|
||||
// Log.w("Relay", "Relay onEOSE $url, $channel")
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
}
|
||||
"NOTICE" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
// "channel" being the second string in the string array ...
|
||||
Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay onOK $url, $channel")
|
||||
Log.w("Relay", "Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}")
|
||||
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
|
||||
}
|
||||
"AUTH" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
|
||||
it.onAuth(this@Relay, msg[1].asString)
|
||||
}
|
||||
else -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay something else $url, $channel")
|
||||
it.onError(
|
||||
@@ -128,6 +155,7 @@ class Relay(
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
socket = null
|
||||
isReady = false
|
||||
afterEOSE = false
|
||||
closingTime = Date().time / 1000
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
|
||||
}
|
||||
@@ -139,6 +167,7 @@ class Relay(
|
||||
// Failures disconnect the relay.
|
||||
socket = null
|
||||
isReady = false
|
||||
afterEOSE = false
|
||||
closingTime = Date().time / 1000
|
||||
|
||||
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
|
||||
@@ -153,6 +182,7 @@ class Relay(
|
||||
} catch (e: Exception) {
|
||||
errorCounter++
|
||||
isReady = false
|
||||
afterEOSE = false
|
||||
closingTime = Date().time / 1000
|
||||
Log.e("Relay", "Relay Invalid $url")
|
||||
e.printStackTrace()
|
||||
@@ -165,6 +195,7 @@ class Relay(
|
||||
socket?.close(1000, "Normal close")
|
||||
socket = null
|
||||
isReady = false
|
||||
afterEOSE = false
|
||||
}
|
||||
|
||||
fun sendFilter(requestId: String) {
|
||||
@@ -174,9 +205,11 @@ class Relay(
|
||||
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
|
||||
if (filters.isNotEmpty()) {
|
||||
val request =
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
|
||||
// println("FILTERSSENT ${url} ${request}")
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
|
||||
// println("FILTERSSENT $url $request")
|
||||
socket?.send(request)
|
||||
eventUploadCounterInBytes += request.bytesUsedInMemory()
|
||||
afterEOSE = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -200,9 +233,18 @@ class Relay(
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
if (signedEvent is RelayAuthEvent) {
|
||||
val event = """["AUTH",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
|
||||
if (write) {
|
||||
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
|
||||
eventUploadCounter++
|
||||
if (signedEvent !is RelayAuthEvent) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +283,8 @@ class Relay(
|
||||
|
||||
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
|
||||
|
||||
fun onAuth(relay: Relay, challenge: String)
|
||||
|
||||
/**
|
||||
* Connected to or disconnected from a relay
|
||||
*
|
||||
@@ -249,3 +293,7 @@ class Relay(
|
||||
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.bytesUsedInMemory(): Int {
|
||||
return (8 * ((((this.length) * 2) + 45) / 8))
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ object RelayPool : Relay.Listener {
|
||||
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
|
||||
|
||||
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
|
||||
|
||||
fun onAuth(relay: Relay, challenge: String)
|
||||
}
|
||||
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
|
||||
@@ -113,6 +115,10 @@ object RelayPool : Relay.Listener {
|
||||
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: RelayPoolLiveData = RelayPoolLiveData(this)
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import java.util.UUID
|
||||
|
||||
data class Subscription(
|
||||
val id: String = UUID.randomUUID().toString().substring(0, 4),
|
||||
val onEOSE: ((Long) -> Unit)? = null
|
||||
val onEOSE: ((Long, String) -> Unit)? = null
|
||||
) {
|
||||
var typedFilters: List<TypedFilter>? = null // Inactive when null
|
||||
|
||||
fun updateEOSE(l: Long) {
|
||||
onEOSE?.let { it(l) }
|
||||
fun updateEOSE(time: Long, relay: String) {
|
||||
onEOSE?.let { it(time, relay) }
|
||||
}
|
||||
|
||||
fun toJson(): String {
|
||||
|
||||
@@ -40,9 +40,15 @@ class TypedFilter(
|
||||
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
|
||||
}
|
||||
}
|
||||
/*
|
||||
Does not include since in the json comparison
|
||||
filter.since?.run {
|
||||
jsonObject.addProperty("since", filter.since)
|
||||
}
|
||||
val jsonObjectSince = JsonObject()
|
||||
entries.forEach { sincePairs ->
|
||||
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
|
||||
}
|
||||
jsonObject.add("since", jsonObjectSince)
|
||||
}*/
|
||||
filter.until?.run {
|
||||
jsonObject.addProperty("until", filter.until)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
@@ -16,8 +19,12 @@ import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.components.muted
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
@@ -25,6 +32,8 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -34,9 +43,22 @@ class MainActivity : FragmentActivity() {
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> "Event/${nip19.hex}"
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
} ?: try {
|
||||
intent?.data?.toString()?.let {
|
||||
Nip47.parse(it)
|
||||
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
|
||||
Route.Home.base + "?nip47=" + encodedUri
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(this.applicationContext)
|
||||
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(this).components {
|
||||
if (SDK_INT >= 28) {
|
||||
@@ -71,6 +93,8 @@ class MainActivity : FragmentActivity() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// starts muted every time
|
||||
muted.value = true
|
||||
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
@@ -94,3 +118,13 @@ class MainActivity : FragmentActivity() {
|
||||
ServiceManager.cleanUp()
|
||||
}
|
||||
}
|
||||
|
||||
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
|
||||
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
return super.createIntent(context, input).apply {
|
||||
// Force only images and videos to be selectable
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,17 @@ import android.media.MediaScannerConnection
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.*
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object ImageSaver {
|
||||
/**
|
||||
@@ -30,7 +35,7 @@ object ImageSaver {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.get()
|
||||
.url(url)
|
||||
.build()
|
||||
@@ -73,6 +78,38 @@ object ImageSaver {
|
||||
})
|
||||
}
|
||||
|
||||
fun saveImage(
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?
|
||||
) {
|
||||
try {
|
||||
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val buffer = localFile.inputStream().source().buffer()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
displayName = UUID.randomUUID().toString(),
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
contentResolver = context.contentResolver
|
||||
)
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = UUID.randomUUID().toString() + ".$extension",
|
||||
contentSource = buffer,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveContentQ(
|
||||
displayName: String,
|
||||
|
||||
@@ -2,65 +2,100 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.io.InputStream
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
|
||||
object ImageUploader {
|
||||
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
server: ServersAvailable,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (String) -> Unit,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
|
||||
val myServer = if (server == ServersAvailable.IMGUR) {
|
||||
ImgurServer()
|
||||
} else if (server == ServersAvailable.NOSTRIMG) {
|
||||
NostrImgServer()
|
||||
} else {
|
||||
ImgurServer()
|
||||
}
|
||||
|
||||
uploadImage(imageInputStream, contentType, myServer, onSuccess, onError)
|
||||
}
|
||||
|
||||
fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
contentType: String?,
|
||||
server: FileServer,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
val fileName = randomChars()
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
val requestBody: RequestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"image",
|
||||
"${UUID.randomUUID()}",
|
||||
category,
|
||||
"$fileName.$extension",
|
||||
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)
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("Authorization", "Client-ID e6aea87296f3f96")
|
||||
.header("User-Agent", "Amethyst")
|
||||
.url("https://api.imgur.com/3/image")
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
server.clientID()?.let {
|
||||
requestBuilder.header("Authorization", it)
|
||||
}
|
||||
|
||||
requestBuilder
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.postUrl(contentType))
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val tree = jacksonObjectMapper().readTree(body.string())
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
val url = server.parseUrlFromSucess(body.string())
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
|
||||
onSuccess(url)
|
||||
onSuccess(url, contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
@@ -75,3 +110,49 @@ object ImageUploader {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
abstract class FileServer {
|
||||
abstract fun postUrl(contentType: String?): String
|
||||
abstract fun parseUrlFromSucess(body: String): String?
|
||||
|
||||
open fun clientID(): String? = null
|
||||
}
|
||||
|
||||
class NostrImgServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
class ImgurServer : FileServer() {
|
||||
override fun postUrl(contentType: String?): String {
|
||||
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
}
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun clientID() = "Client-ID e6aea87296f3f96"
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/amethyst.php"
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URL
|
||||
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError = MutableSharedFlow<String?>()
|
||||
var mediaType by mutableStateOf<String?>(null)
|
||||
|
||||
var selectedServer by mutableStateOf<ServersAvailable?>(null)
|
||||
var description by mutableStateOf("")
|
||||
|
||||
// Images and Videos
|
||||
var galleryUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
var uploadingPercentage = mutableStateOf(0.0f)
|
||||
var uploadingDescription = mutableStateOf<String?>(null)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
open fun load(account: Account, uri: Uri, contentType: String?) {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
this.mediaType = contentType
|
||||
this.selectedServer = defaultServer()
|
||||
|
||||
if (selectedServer == ServersAvailable.IMGUR) {
|
||||
selectedServer = ServersAvailable.IMGUR_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
|
||||
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(context: Context) {
|
||||
isUploadingImage = true
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val uri = galleryUri ?: return
|
||||
val serverToUse = selectedServer ?: return
|
||||
|
||||
if (selectedServer == ServersAvailable.NIP95) {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Loading"
|
||||
val contentType = contentResolver.getType(uri)
|
||||
contentResolver.openInputStream(uri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description)
|
||||
}
|
||||
?: run {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Uploading"
|
||||
ImageUploader.uploadImage(
|
||||
uri = uri,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
galleryUri = null
|
||||
isUploadingImage = false
|
||||
mediaType = null
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.0f
|
||||
|
||||
description = ""
|
||||
selectedServer = account?.defaultFileServer
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(15000)
|
||||
}
|
||||
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
try {
|
||||
val imageData = URL(imageUrl).readBytes()
|
||||
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
val note = account?.sendHeader(it)
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.20f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
uploadingDescription.value = "Signing"
|
||||
uploadingPercentage.value = 0.30f
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
|
||||
if (nip95 != null) {
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.sendNip95(nip95.first, nip95.second)
|
||||
}
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
},
|
||||
onError = {
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.00f
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun defaultServer() = account?.defaultFileServer
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val context = LocalContext.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight().imePadding()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context)
|
||||
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroolState)
|
||||
) {
|
||||
ImageVideoPost(postViewModel, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
val fileServerOptions = fileServers.map { it.second }
|
||||
val fileServerExplainers = fileServers.map { it.third }
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
if (postViewModel.isImage() == true) {
|
||||
AsyncImage(
|
||||
model = postViewModel.galleryUri.toString(),
|
||||
contentDescription = postViewModel.galleryUri.toString(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
)
|
||||
} else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.galleryUri?.let {
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
postViewModel.imageUploadingError.emit("Unable to load file")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
|
||||
options = fileServerOptions,
|
||||
explainers = fileServerExplainers,
|
||||
onSelect = {
|
||||
postViewModel.selectedServer = fileServers[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
postViewModel.selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = postViewModel.description,
|
||||
onValueChange = { postViewModel.description = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
|
||||
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
||||
|
||||
fun addUserToMentions(user: User) {
|
||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||
}
|
||||
|
||||
fun addNoteToReplyTos(note: Note) {
|
||||
note.author?.let { addUserToMentions(it) }
|
||||
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
|
||||
}
|
||||
|
||||
fun tagIndex(user: User): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||
}
|
||||
|
||||
fun tagIndex(note: Note): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
}
|
||||
|
||||
fun run() {
|
||||
// adds all references to mentions and reply tos
|
||||
message.split('\n').forEach { paragraph: String ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
addNoteToReplyTos(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags the text in the correct order.
|
||||
message = message.split('\n').map { paragraph: String ->
|
||||
paragraph.split(' ').map { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
val user = LocalCache.getOrCreateUser(results.key.hex)
|
||||
|
||||
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
"nostr:${note.idNote()}${results.restOfWord}"
|
||||
} else {
|
||||
word
|
||||
}
|
||||
} else {
|
||||
word
|
||||
}
|
||||
}.joinToString(" ")
|
||||
}.joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
|
||||
@Composable
|
||||
fun NewPollClosing(pollViewModel: NewPostViewModel) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
pollViewModel.isValidClosedAt.value = true
|
||||
if (text.isNotEmpty()) {
|
||||
try {
|
||||
val int = text.toInt()
|
||||
if (int < 0) {
|
||||
pollViewModel.isValidClosedAt.value = false
|
||||
} else { pollViewModel.closedAt = int }
|
||||
} catch (e: Exception) { pollViewModel.isValidClosedAt.value = false }
|
||||
}
|
||||
|
||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.error,
|
||||
unfocusedBorderColor = Color.Red
|
||||
)
|
||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_closing_time),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_closing_time_days),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewPollClosingPreview() {
|
||||
NewPollClosing(NewPostViewModel())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
|
||||
@Composable
|
||||
fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
pollViewModel.isValidConsensusThreshold.value = true
|
||||
if (text.isNotEmpty()) {
|
||||
try {
|
||||
val int = text.toInt()
|
||||
if (int < 0 || int > 100) {
|
||||
pollViewModel.isValidConsensusThreshold.value = false
|
||||
} else { pollViewModel.consensusThreshold = int }
|
||||
} catch (e: Exception) { pollViewModel.isValidConsensusThreshold.value = false }
|
||||
}
|
||||
|
||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.error,
|
||||
unfocusedBorderColor = Color.Red
|
||||
)
|
||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_consensus_threshold),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_consensus_threshold_percent),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewPollConsensusThresholdPreview() {
|
||||
NewPollConsensusThreshold(NewPostViewModel())
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Composable
|
||||
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
|
||||
Row {
|
||||
val deleteIcon: @Composable (() -> Unit) = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
pollViewModel.pollOptions.remove(optionIndex)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.weight(1F),
|
||||
value = pollViewModel.pollOptions[optionIndex] ?: "",
|
||||
onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_option_description),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
// colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else colorInValid,
|
||||
trailingIcon = if (optionIndex > 1) deleteIcon else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewPollOptionPreview() {
|
||||
NewPollOption(NewPostViewModel(), 0)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
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 com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
var isInputValid = true
|
||||
if (pollViewModel.message.text.isEmpty()) {
|
||||
isInputValid = false
|
||||
}
|
||||
|
||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.error,
|
||||
unfocusedBorderColor = Color.Red
|
||||
)
|
||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = pollViewModel.message,
|
||||
onValueChange = {
|
||||
pollViewModel.updateMessage(it)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_primary_description),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_primary_description),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
colors = if (isInputValid) colorValid else colorInValid,
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
|
||||
@Composable
|
||||
fun NewPollRecipientsField(pollViewModel: NewPostViewModel, account: Account) {
|
||||
// if no recipients, add user's pubkey
|
||||
if (pollViewModel.zapRecipients.isEmpty()) {
|
||||
pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
// TODO allow add multiple recipients and check input validity
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
value = pollViewModel.zapRecipients[0],
|
||||
onValueChange = { /* TODO */ },
|
||||
enabled = false, // TODO enable add recipients
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_zap_recipients),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_zap_recipients),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
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.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
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.res.stringResource
|
||||
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.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
|
||||
val pollViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
pollViewModel.load(account, baseReplyTo, quote)
|
||||
delay(100)
|
||||
|
||||
pollViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.imePadding()
|
||||
.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
pollViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PollButton(
|
||||
onPost = {
|
||||
pollViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = pollViewModel.message.text.isNotBlank() &&
|
||||
pollViewModel.pollOptions.values.all { it.isNotEmpty() } &&
|
||||
pollViewModel.isValidRecipients.value &&
|
||||
pollViewModel.isValidvalueMaximum.value &&
|
||||
pollViewModel.isValidvalueMinimum.value &&
|
||||
pollViewModel.isValidConsensusThreshold.value &&
|
||||
pollViewModel.isValidClosedAt.value
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
if (pollViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
|
||||
ReplyInformation(pollViewModel.replyTos, pollViewModel.mentions, account, "✖ ") {
|
||||
pollViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.poll_heading_required))
|
||||
// NewPollRecipientsField(pollViewModel, account)
|
||||
NewPollPrimaryDescription(pollViewModel)
|
||||
pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(pollViewModel, index)
|
||||
}
|
||||
Button(
|
||||
onClick = { pollViewModel.pollOptions[pollViewModel.pollOptions.size] = "" },
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = android.R.drawable.ic_input_add),
|
||||
contentDescription = "Add poll option button",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Text(stringResource(R.string.poll_heading_optional))
|
||||
NewPollVoteValueRange(pollViewModel)
|
||||
NewPollConsensusThreshold(pollViewModel)
|
||||
NewPollClosing(pollViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
val userSuggestions = pollViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserLine(item, account) {
|
||||
pollViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
/*UploadFromGallery(
|
||||
isUploading = pollViewModel.isUploadingImage
|
||||
) {
|
||||
pollViewModel.upload(it, context)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PollButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) {
|
||||
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.post_poll), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
/*@Preview
|
||||
@Composable
|
||||
fun NewPollViewPreview() {
|
||||
NewPollView(onClose = {}, account = Account(loggedIn = Persona()))
|
||||
}*/
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Composable
|
||||
fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
|
||||
var textMax by rememberSaveable { mutableStateOf("") }
|
||||
var textMin by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
// check for zapMax amounts < 1
|
||||
pollViewModel.isValidvalueMaximum.value = true
|
||||
if (textMax.isNotEmpty()) {
|
||||
try {
|
||||
val int = textMax.toInt()
|
||||
if (int < 1) {
|
||||
pollViewModel.isValidvalueMaximum.value = false
|
||||
} else { pollViewModel.valueMaximum = int }
|
||||
} catch (e: Exception) { pollViewModel.isValidvalueMaximum.value = false }
|
||||
}
|
||||
|
||||
// check for minZap amounts < 1
|
||||
pollViewModel.isValidvalueMinimum.value = true
|
||||
if (textMin.isNotEmpty()) {
|
||||
try {
|
||||
val int = textMin.toInt()
|
||||
if (int < 1) {
|
||||
pollViewModel.isValidvalueMinimum.value = false
|
||||
} else { pollViewModel.valueMinimum = int }
|
||||
} catch (e: Exception) { pollViewModel.isValidvalueMinimum.value = false }
|
||||
}
|
||||
|
||||
// check for zapMin > zapMax
|
||||
if (textMin.isNotEmpty() && textMax.isNotEmpty()) {
|
||||
try {
|
||||
val intMin = textMin.toInt()
|
||||
val intMax = textMax.toInt()
|
||||
|
||||
if (intMin > intMax) {
|
||||
pollViewModel.isValidvalueMinimum.value = false
|
||||
pollViewModel.isValidvalueMaximum.value = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pollViewModel.isValidvalueMinimum.value = false
|
||||
pollViewModel.isValidvalueMaximum.value = false
|
||||
}
|
||||
}
|
||||
|
||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.error,
|
||||
unfocusedBorderColor = Color.Red
|
||||
)
|
||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = textMin,
|
||||
onValueChange = { textMin = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_zap_value_min),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.sats),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = textMax,
|
||||
onValueChange = { textMax = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_zap_value_max),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.sats),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewPollVoteValueRangePreview() {
|
||||
NewPollVoteValueRange(NewPostViewModel())
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -10,9 +16,20 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowForwardIos
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -21,17 +38,22 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
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 androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -39,12 +61,17 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
val context = LocalContext.current
|
||||
@@ -104,8 +131,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = postViewModel.message.text.isNotBlank() &&
|
||||
!postViewModel.isUploadingImage
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,13 +187,72 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
|
||||
if (postViewModel.wantsPoll) {
|
||||
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(postViewModel, index)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" },
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = android.R.drawable.ic_input_add),
|
||||
contentDescription = "Add poll option button",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val url = postViewModel.contentToAddUrl
|
||||
if (url != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
account.defaultFileServer,
|
||||
onAdd = { description, server ->
|
||||
postViewModel.upload(url, description, server, context)
|
||||
account.changeDefaultFileServer(server)
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val user = postViewModel.account?.userProfile()
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
|
||||
if (lud16 != null && postViewModel.wantsInvoice) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
user.pubkeyHex,
|
||||
account,
|
||||
stringResource(id = R.string.lightning_invoice),
|
||||
stringResource(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it)
|
||||
postViewModel.wantsInvoice = false
|
||||
},
|
||||
onClose = {
|
||||
postViewModel.wantsInvoice = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
@@ -182,13 +267,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl)
|
||||
.matches()
|
||||
) {
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
VideoView(myUrlPreview)
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview)
|
||||
}
|
||||
} else if (isBechLink(myUrlPreview)) {
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
MaterialTheme.colors.background,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview)
|
||||
}
|
||||
@@ -216,13 +307,36 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(bottom = 10.dp)
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier
|
||||
) {
|
||||
postViewModel.upload(it, context)
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
val hashtag = stringResource(R.string.poll_hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddInvoice) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
}
|
||||
|
||||
ForwardZapTo(postViewModel) {
|
||||
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +345,143 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddPollButton(
|
||||
isPollActive: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
if (!isPollActive) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_poll),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_lists),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddLnInvoiceButton(
|
||||
isLnInvoiceActive: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
if (!isLnInvoiceActive) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ForwardZapTo(
|
||||
postViewModel: NewPostViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp)
|
||||
) {
|
||||
if (!postViewModel.wantsForwardZapTo) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowForwardIos,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowForwardIos,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsForwardZapTo) {
|
||||
OutlinedTextField(
|
||||
value = postViewModel.forwardZapToEditting,
|
||||
onValueChange = {
|
||||
postViewModel.updateZapForwardTo(it)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.padding(0.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.zap_forward_lnAddress),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CloseButton(onCancel: () -> Unit) {
|
||||
Button(
|
||||
@@ -332,3 +583,206 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ServersAvailable {
|
||||
IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoDescription(
|
||||
uri: Uri,
|
||||
defaultServer: ServersAvailable,
|
||||
onAdd: (String, ServersAvailable) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isImage = mediaType.startsWith("image")
|
||||
val isVideo = mediaType.startsWith("video")
|
||||
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
val fileServerOptions = fileServers.map { it.second }
|
||||
val fileServerExplainers = fileServers.map { it.third }
|
||||
|
||||
var selectedServer by remember { mutableStateOf(defaultServer) }
|
||||
var message by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isImage) {
|
||||
R.string.content_description_add_image
|
||||
} else {
|
||||
if (isVideo) {
|
||||
R.string.content_description_add_video
|
||||
} else {
|
||||
R.string.content_description_add_document
|
||||
}
|
||||
}
|
||||
),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1.0f)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = onCancel
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Cancel,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
.size(30.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
if (mediaType.startsWith("image")) {
|
||||
AsyncImage(
|
||||
model = uri.toString(),
|
||||
contentDescription = uri.toString(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
)
|
||||
} else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = uri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(uri)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
|
||||
options = fileServerOptions,
|
||||
explainers = fileServerExplainers,
|
||||
onSelect = {
|
||||
selectedServer = fileServers[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
onAdd(message, selectedServer)
|
||||
},
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,29 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NewPostViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var originalNote: Note? = null
|
||||
open class NewPostViewModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
var originalNote: Note? = null
|
||||
|
||||
var mentions by mutableStateOf<List<User>?>(null)
|
||||
var replyTos by mutableStateOf<List<Note>?>(null)
|
||||
@@ -31,8 +37,37 @@ class NewPostViewModel : ViewModel() {
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
var userSuggestionsMainMessage: Boolean? = null
|
||||
|
||||
fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
// Images and Videos
|
||||
var contentToAddUrl by mutableStateOf<Uri?>(null)
|
||||
|
||||
// Polls
|
||||
var canUsePoll by mutableStateOf(false)
|
||||
var wantsPoll by mutableStateOf(false)
|
||||
var zapRecipients = mutableStateListOf<HexKey>()
|
||||
var pollOptions = newStateMapPollOptions()
|
||||
var valueMaximum: Int? = null
|
||||
var valueMinimum: Int? = null
|
||||
var consensusThreshold: Int? = null
|
||||
var closedAt: Int? = null
|
||||
|
||||
var isValidRecipients = mutableStateOf(true)
|
||||
var isValidvalueMaximum = mutableStateOf(true)
|
||||
var isValidvalueMinimum = mutableStateOf(true)
|
||||
var isValidConsensusThreshold = mutableStateOf(true)
|
||||
var isValidClosedAt = mutableStateOf(true)
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
|
||||
// Forward Zap to
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
var forwardZapTo by mutableStateOf<User?>(null)
|
||||
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
@@ -47,118 +82,116 @@ class NewPostViewModel : ViewModel() {
|
||||
this.mentions = currentMentions.plus(replyUser)
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
replyTos = null
|
||||
mentions = null
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
|
||||
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
canAddInvoice = account.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
forwardZapTo = null
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
this.account = account
|
||||
}
|
||||
|
||||
fun addUserToMentions(user: User) {
|
||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||
}
|
||||
|
||||
fun addNoteToReplyTos(note: Note) {
|
||||
note.author?.let { addUserToMentions(it) }
|
||||
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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() {
|
||||
// adds all references to mentions and reply tos
|
||||
message.text.split('\n').forEach { paragraph: String ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
|
||||
tagger.run()
|
||||
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
addNoteToReplyTos(note)
|
||||
}
|
||||
}
|
||||
val zapReceiver = if (wantsForwardZapTo) {
|
||||
if (forwardZapTo != null) {
|
||||
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
|
||||
} else {
|
||||
forwardZapToEditting.text
|
||||
}
|
||||
}
|
||||
|
||||
// Tags the text in the correct order.
|
||||
val newMessage = message.text.split('\n').map { paragraph: String ->
|
||||
paragraph.split(' ').map { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
val user = LocalCache.getOrCreateUser(results.key.hex)
|
||||
|
||||
"#[${tagIndex(user)}]${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
} else {
|
||||
word
|
||||
}
|
||||
} else {
|
||||
word
|
||||
}
|
||||
}.joinToString(" ")
|
||||
}.joinToString("\n")
|
||||
|
||||
if (originalNote?.channel() != null) {
|
||||
account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions)
|
||||
} else {
|
||||
account?.sendPost(newMessage, replyTos, mentions)
|
||||
null
|
||||
}
|
||||
|
||||
message = TextFieldValue("")
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
mentions = null
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver)
|
||||
} else if (originalNote?.channel() != null) {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver)
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver)
|
||||
} else {
|
||||
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver)
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
fun upload(it: Uri, context: Context) {
|
||||
fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) {
|
||||
isUploadingImage = true
|
||||
contentToAddUrl = null
|
||||
|
||||
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")
|
||||
}
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (server == ServersAvailable.NIP95) {
|
||||
val contentType = contentResolver.getType(it)
|
||||
contentResolver.openInputStream(it)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94) {
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
} else {
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
open fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
contentToAddUrl = null
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
mentions = null
|
||||
|
||||
wantsPoll = false
|
||||
zapRecipients = mutableStateListOf<HexKey>()
|
||||
pollOptions = newStateMapPollOptions()
|
||||
valueMaximum = null
|
||||
valueMinimum = null
|
||||
consensusThreshold = null
|
||||
closedAt = null
|
||||
|
||||
wantsInvoice = false
|
||||
|
||||
wantsForwardZapTo = false
|
||||
forwardZapTo = null
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
userSuggestions = emptyList()
|
||||
userSuggestionAnchor = null
|
||||
userSuggestionsMainMessage = null
|
||||
}
|
||||
|
||||
fun findUrlInMessage(): String? {
|
||||
open fun findUrlInMessage(): String? {
|
||||
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
paragraph.split(' ').firstOrNull { word: String ->
|
||||
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
|
||||
@@ -166,17 +199,18 @@ class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFromReplyList(it: User) {
|
||||
open fun removeFromReplyList(it: User) {
|
||||
mentions = mentions?.minus(it)
|
||||
}
|
||||
|
||||
fun updateMessage(it: TextFieldValue) {
|
||||
open fun updateMessage(it: TextFieldValue) {
|
||||
message = it
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
if (it.selection.collapsed) {
|
||||
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
userSuggestionAnchor = it.selection
|
||||
userSuggestionsMainMessage = true
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
} else {
|
||||
@@ -185,18 +219,138 @@ class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun autocompleteWithUser(item: User) {
|
||||
userSuggestionAnchor?.let {
|
||||
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()} "
|
||||
open fun updateZapForwardTo(it: TextFieldValue) {
|
||||
forwardZapToEditting = it
|
||||
if (it.selection.collapsed) {
|
||||
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
userSuggestionAnchor = it.selection
|
||||
userSuggestionsMainMessage = false
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
} else {
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun autocompleteWithUser(item: User) {
|
||||
userSuggestionAnchor?.let {
|
||||
if (userSuggestionsMainMessage == true) {
|
||||
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
|
||||
message = TextFieldValue(
|
||||
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)
|
||||
} else {
|
||||
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
forwardZapTo = item
|
||||
|
||||
forwardZapToEditting = TextFieldValue(
|
||||
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)
|
||||
}
|
||||
|
||||
message = TextFieldValue(
|
||||
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)
|
||||
userSuggestionAnchor = null
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
|
||||
return mutableStateMapOf(Pair(0, ""), Pair(1, ""))
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
|
||||
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
|
||||
}
|
||||
|
||||
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
|
||||
if (include) {
|
||||
updateMessage(TextFieldValue(message.text + " $hashtag"))
|
||||
} else {
|
||||
updateMessage(
|
||||
TextFieldValue(
|
||||
message.text.replace(" $hashtag", "")
|
||||
.replace(hashtag, "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendHeader(it)
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
if (note == null) {
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
} else {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:" + note.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
note?.let {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:" + it.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectImage(uri: Uri) {
|
||||
contentToAddUrl = uri
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,27 +150,27 @@ fun ServerConfigHeader() {
|
||||
|
||||
Column(Modifier.weight(1.4f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.size(25.dp))
|
||||
Spacer(modifier = Modifier.size(30.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.posts),
|
||||
text = stringResource(R.string.bytes),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.posts),
|
||||
text = stringResource(id = R.string.bytes),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.errors),
|
||||
@@ -371,10 +371,10 @@ fun ServerConfig(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.downloadCount)}",
|
||||
text = "${countToHumanReadable(item.downloadCountInBytes)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
@@ -399,10 +399,10 @@ fun ServerConfig(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.uploadCount)}",
|
||||
text = "${countToHumanReadable(item.uploadCountInBytes)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
@@ -418,7 +418,7 @@ fun ServerConfig(
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.errorCount)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
@@ -433,7 +433,7 @@ fun ServerConfig(
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.spamCount)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
@@ -49,23 +49,23 @@ class NewRelayListViewModel : ViewModel() {
|
||||
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
|
||||
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
}.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
} else {
|
||||
account.localRelays.map {
|
||||
val liveRelay = RelayPool.getRelay(it.url)
|
||||
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
}.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user