mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
800 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffb81c6246 | ||
|
|
b7c9b14b1f | ||
|
|
53b0db61ea | ||
|
|
6c09e47e4f | ||
|
|
9d9ad63b4d | ||
|
|
7ef46a2662 | ||
|
|
a7d8c87c47 | ||
|
|
1bb2459277 | ||
|
|
6b39562925 | ||
|
|
3d34e1fc4e | ||
|
|
469e1241d5 | ||
|
|
86c0ca975b | ||
|
|
53eeea3225 | ||
|
|
e9ff9d3b37 | ||
|
|
e2c011ec60 | ||
|
|
5328740bff | ||
|
|
017df9845d | ||
|
|
ed70658be9 | ||
|
|
dc74c3014f | ||
|
|
53d5c5f354 | ||
|
|
63ab2a9fbe | ||
|
|
30391160da | ||
|
|
3273aa32a5 | ||
|
|
1e358d31d5 | ||
|
|
b57dd98059 | ||
|
|
5b596d2a56 | ||
|
|
62db68c7b2 | ||
|
|
b3bb85cd53 | ||
|
|
fe6fb8470d | ||
|
|
ee97eac00c | ||
|
|
48e3ccc165 | ||
|
|
52c0503b6d | ||
|
|
c052399a16 | ||
|
|
76faf7ae35 | ||
|
|
36ea33a919 | ||
|
|
8aad4d8030 | ||
|
|
a23c8c5c04 | ||
|
|
0434ef6ec6 | ||
|
|
4d85a2f6fb | ||
|
|
e9b235f748 | ||
|
|
30320805da | ||
|
|
8e913ef974 | ||
|
|
eb191406e6 | ||
|
|
ca89b70150 | ||
|
|
6bcd6950ef | ||
|
|
dfa9c77e3e | ||
|
|
af143f2ea1 | ||
|
|
52761b3901 | ||
|
|
f027d23fcf | ||
|
|
2d30f3e9d2 | ||
|
|
a63b383425 | ||
|
|
e04eb733e7 | ||
|
|
0a49298ec2 | ||
|
|
1121cfcaf9 | ||
|
|
66b5fad6c2 | ||
|
|
5d352f1b6c | ||
|
|
9018175f54 | ||
|
|
98c55661e8 | ||
|
|
521407f879 | ||
|
|
fa4257ad7d | ||
|
|
d73dec8f0b | ||
|
|
c6124376df | ||
|
|
87c19f4232 | ||
|
|
a1f116909f | ||
|
|
ca9951f9b8 | ||
|
|
2bedd79e6f | ||
|
|
a9af2d8982 | ||
|
|
655a563f61 | ||
|
|
9777c709a1 | ||
|
|
251f4854fc | ||
|
|
43c32f0b0e | ||
|
|
ec3b07147c | ||
|
|
0e6a2c339e | ||
|
|
844f7cd478 | ||
|
|
a2edc9f3b2 | ||
|
|
35d09aee77 | ||
|
|
a79d1d5bd6 | ||
|
|
ba9315fcd1 | ||
|
|
f5c71b4f90 | ||
|
|
5a40b39952 | ||
|
|
2ac3b42d4d | ||
|
|
7cfad373f2 | ||
|
|
5d061a79e2 | ||
|
|
eb59a9d505 | ||
|
|
784f867a8e | ||
|
|
954a9d66fb | ||
|
|
dfdced3e8c | ||
|
|
0af141c5b7 | ||
|
|
70c4c69bbf | ||
|
|
5597189ea5 | ||
|
|
8884adcefb | ||
|
|
92be03ca51 | ||
|
|
b4f9103b62 | ||
|
|
63f3a6cdf9 | ||
|
|
d8ce3b9d37 | ||
|
|
2ac5742b0e | ||
|
|
58dd69091e | ||
|
|
7a93f9956f | ||
|
|
8dbded0185 | ||
|
|
41b93e3860 | ||
|
|
2f741779fa | ||
|
|
918273d825 | ||
|
|
eef534617c | ||
|
|
4e89b1494d | ||
|
|
be1ae35a60 | ||
|
|
47395465bb | ||
|
|
c49f89f711 | ||
|
|
6721c83266 | ||
|
|
ce27be2053 | ||
|
|
fee2a0e4f8 | ||
|
|
52fcfebe38 | ||
|
|
0e1a856555 | ||
|
|
255b48d126 | ||
|
|
99adbf6691 | ||
|
|
a96760d1a4 | ||
|
|
dd9e51e31c | ||
|
|
a5b607b30b | ||
|
|
f7e9898637 | ||
|
|
1ad1d233cd | ||
|
|
52687a48df | ||
|
|
dca4f827fe | ||
|
|
2726011126 | ||
|
|
77bd646b1d | ||
|
|
d664d9ff19 | ||
|
|
5d24950d7f | ||
|
|
3a8a50006d | ||
|
|
decd6ef627 | ||
|
|
6ab061a5a2 | ||
|
|
d934e28b3a | ||
|
|
57588fd6ac | ||
|
|
921d52cb8c | ||
|
|
4a2d673cce | ||
|
|
c7a3a74d5a | ||
|
|
eed2f6abaf | ||
|
|
80f0b14c02 | ||
|
|
b12b81282c | ||
|
|
07d661ea00 | ||
|
|
a15263eeb9 | ||
|
|
6f19bccc54 | ||
|
|
547d567431 | ||
|
|
ece4e9074a | ||
|
|
0737c6ddc3 | ||
|
|
2aa9458aa1 | ||
|
|
cb3b898e29 | ||
|
|
ceac61eb19 | ||
|
|
bc9f5e5328 | ||
|
|
a293800897 | ||
|
|
3be34d0d40 | ||
|
|
eec87f017a | ||
|
|
799037502f | ||
|
|
f2b727c587 | ||
|
|
b255d1827e | ||
|
|
d9e01da7e2 | ||
|
|
99a98439cd | ||
|
|
724e7e2378 | ||
|
|
0619c9ffac | ||
|
|
cb7b51e8d1 | ||
|
|
c8172265dd | ||
|
|
cce9d424bc | ||
|
|
45227b75dc | ||
|
|
6eaba7956c | ||
|
|
cc4b94f738 | ||
|
|
423628a104 | ||
|
|
d262b48f31 | ||
|
|
f97a0468cc | ||
|
|
b4d87686ac | ||
|
|
8cbf07c917 | ||
|
|
30fc4c35f4 | ||
|
|
7912d492e5 | ||
|
|
d921eb6138 | ||
|
|
6875fdc4d0 | ||
|
|
0bc701f5c9 | ||
|
|
5de0808b1e | ||
|
|
9d680c9f82 | ||
|
|
63fc4c570d | ||
|
|
ab2fff0194 | ||
|
|
c644ba9a3e | ||
|
|
2791873048 | ||
|
|
89266bc76f | ||
|
|
9573b4abec | ||
|
|
ddcff8fa15 | ||
|
|
d36f6f4a7e | ||
|
|
c2b72f1e45 | ||
|
|
28e6471ade | ||
|
|
c3b8eb5087 | ||
|
|
75779e7ddc | ||
|
|
118bc7b73c | ||
|
|
e6a0fdedd9 | ||
|
|
28098fafd7 | ||
|
|
0c0e87af21 | ||
|
|
8abfd7149b | ||
|
|
674896cea4 | ||
|
|
ec514651fc | ||
|
|
8071d48911 | ||
|
|
69daa3835b | ||
|
|
57dac276b2 | ||
|
|
3a84595aac | ||
|
|
7b591c84f8 | ||
|
|
e8c8167451 | ||
|
|
ec93fc53b2 | ||
|
|
ba2902b725 | ||
|
|
e7defa14b9 | ||
|
|
4fbc3f25b3 | ||
|
|
4ae88d5cc8 | ||
|
|
1aea0720df | ||
|
|
846f340f84 | ||
|
|
cb137205c0 | ||
|
|
f910709e1b | ||
|
|
1d78dd6b19 | ||
|
|
3197be8a75 | ||
|
|
91ed4128fe | ||
|
|
f415490fcd | ||
|
|
4bdb5a28d0 | ||
|
|
58efe0b3c0 | ||
|
|
da3cecd324 | ||
|
|
09b1451dc1 | ||
|
|
9ae31a3429 | ||
|
|
b5258f89c1 | ||
|
|
fcc67fc4f2 | ||
|
|
579465ac6c | ||
|
|
6cbee81e72 | ||
|
|
ad683ed444 | ||
|
|
c53b1571a3 | ||
|
|
2c19b2ffef | ||
|
|
3694d1b0f5 | ||
|
|
e7a4746d22 | ||
|
|
7961f6f065 | ||
|
|
7c1c500990 | ||
|
|
5f85d8a97b | ||
|
|
eb77db0469 | ||
|
|
a62445a52f | ||
|
|
553a1fe673 | ||
|
|
a50b0b7902 | ||
|
|
23029a2022 | ||
|
|
bd7156082b | ||
|
|
bdd2a97ee3 | ||
|
|
a2acec7900 | ||
|
|
c4a9a2d557 | ||
|
|
24481459a7 | ||
|
|
14d2d7439e | ||
|
|
dae8877983 | ||
|
|
81f2e72f0d | ||
|
|
a993e4b378 | ||
|
|
de14d4e0f1 | ||
|
|
fd58f063ac | ||
|
|
4593ce582d | ||
|
|
8d8fb8f5dc | ||
|
|
3e4a4f7442 | ||
|
|
d5ad6b983a | ||
|
|
30d31b35ab | ||
|
|
18839b8853 | ||
|
|
e46a77f5ef | ||
|
|
2c8c0a8073 | ||
|
|
570bf1c2e7 | ||
|
|
1d1dc757ee | ||
|
|
50f0085118 | ||
|
|
1edfc6bfbd | ||
|
|
5596c54107 | ||
|
|
ffe1d737fb | ||
|
|
9b36b81721 | ||
|
|
800c1e1a44 | ||
|
|
a011ef128a | ||
|
|
2e8760abe5 | ||
|
|
6acfaa97f3 | ||
|
|
4426ccada7 | ||
|
|
da02f36663 | ||
|
|
e240cd04d4 | ||
|
|
16a7fdffcf | ||
|
|
2fdb4e47b0 | ||
|
|
a316351ba7 | ||
|
|
492bc76957 | ||
|
|
e8170398e3 | ||
|
|
ff233c699b | ||
|
|
0d1cbce5de | ||
|
|
3e7bbc03c1 | ||
|
|
5ba091de0d | ||
|
|
1098c31787 | ||
|
|
c20277a754 | ||
|
|
a5e40d5ac9 | ||
|
|
aab42bb341 | ||
|
|
748349244e | ||
|
|
9e9c8befe0 | ||
|
|
b9fb11bdf3 | ||
|
|
cd6f9576cc | ||
|
|
b919b165d6 | ||
|
|
a2d9a3f770 | ||
|
|
39fccd5442 | ||
|
|
cfeaccb26e | ||
|
|
08a2a2b428 | ||
|
|
297bcf2762 | ||
|
|
092d333378 | ||
|
|
eb58e9ea39 | ||
|
|
63c498da87 | ||
|
|
b40dcb4bd8 | ||
|
|
49fe1d0743 | ||
|
|
25c40b89d7 | ||
|
|
bfe1e78e57 | ||
|
|
fa79ddc573 | ||
|
|
653e8200ca | ||
|
|
8806ca6534 | ||
|
|
897306e16e | ||
|
|
1e25688f9d | ||
|
|
2b6abcbfb9 | ||
|
|
ce33190177 | ||
|
|
d68b14532d | ||
|
|
e83cfe4f07 | ||
|
|
d3e9cc0788 | ||
|
|
87990026ea | ||
|
|
7923f5d854 | ||
|
|
69eea8824a | ||
|
|
a1517a67f5 | ||
|
|
480ae479af | ||
|
|
94213ebf83 | ||
|
|
eedce96453 | ||
|
|
d6886f51f7 | ||
|
|
0497f1e625 | ||
|
|
95ac046a09 | ||
|
|
3e6938bf51 | ||
|
|
786b8436f8 | ||
|
|
b055e520c2 | ||
|
|
9d75fb7074 | ||
|
|
5434f0fef7 | ||
|
|
ed68ca5f26 | ||
|
|
4eea36678e | ||
|
|
15ce0b3e05 | ||
|
|
eb73da1993 | ||
|
|
01ba21d303 | ||
|
|
442eecf916 | ||
|
|
231d02ac0d | ||
|
|
43b011d3da | ||
|
|
8a12cc3cce | ||
|
|
7e03870c0b | ||
|
|
a6f56416e3 | ||
|
|
efe7772135 | ||
|
|
15c96ce007 | ||
|
|
78d2a712c5 | ||
|
|
2664292993 | ||
|
|
b9813c2c8f | ||
|
|
0c00e6512f | ||
|
|
cf31bfe5ea | ||
|
|
5cdceb5194 | ||
|
|
2c82e6c447 | ||
|
|
f56f3b6105 | ||
|
|
9420ddc5c7 | ||
|
|
ade1d83817 | ||
|
|
9a517380a0 | ||
|
|
6fbb59ddac | ||
|
|
fe4a4b6fad | ||
|
|
8dd5705f02 | ||
|
|
6723225885 | ||
|
|
fb19454259 | ||
|
|
c6f63a13d3 | ||
|
|
77057fcac5 | ||
|
|
676d5e9b88 | ||
|
|
d6dda0053a | ||
|
|
d33e62367e | ||
|
|
47d7a53c1c | ||
|
|
6fcf472638 | ||
|
|
e2f5a1a0e4 | ||
|
|
8ea25e735d | ||
|
|
a9d05070ed | ||
|
|
d78ec6b02a | ||
|
|
8971b4e190 | ||
|
|
dea0adaea4 | ||
|
|
5de4fbcbe7 | ||
|
|
2330e086ea | ||
|
|
11d51b3854 | ||
|
|
008469adbc | ||
|
|
bbe26c7a30 | ||
|
|
c2b9bb413d | ||
|
|
5c1c4f8db2 | ||
|
|
b46bb7ecc5 | ||
|
|
2fed65db8d | ||
|
|
66cc4b5526 | ||
|
|
91488f0350 | ||
|
|
f2429e011e | ||
|
|
1374f36b1f | ||
|
|
d185e65b3f | ||
|
|
64bf17b1a0 | ||
|
|
93b50468fd | ||
|
|
65ab756c2b | ||
|
|
e298285145 | ||
|
|
d730eeb9a1 | ||
|
|
0d63441611 | ||
|
|
f0ecf382e0 | ||
|
|
d0dee84031 | ||
|
|
ad59b691fd | ||
|
|
a256e34878 | ||
|
|
6ad00c112a | ||
|
|
0010a19c24 | ||
|
|
d78c7a91cb | ||
|
|
4a2dced02f | ||
|
|
2d1a63be57 | ||
|
|
9e207d0f24 | ||
|
|
efad94ddfd | ||
|
|
d49fde755e | ||
|
|
0224af18c0 | ||
|
|
53a4dfb702 | ||
|
|
4e4e798133 | ||
|
|
e8bd4a12ac | ||
|
|
dffed6e78f | ||
|
|
46a4f360f7 | ||
|
|
64a5071345 | ||
|
|
1fba93b4f0 | ||
|
|
caa67b03a8 | ||
|
|
5f1b2f9eb8 | ||
|
|
2968cb12aa | ||
|
|
8261b38de9 | ||
|
|
2c4489a0d0 | ||
|
|
52dcb73fd7 | ||
|
|
91a2f8ecfa | ||
|
|
6905e1b47f | ||
|
|
640b0d3174 | ||
|
|
a86cd8772f | ||
|
|
83be195a43 | ||
|
|
14bf73e2f1 | ||
|
|
efb7b06e4e | ||
|
|
8b9f37d678 | ||
|
|
e83f16f5c5 | ||
|
|
e648970b17 | ||
|
|
44badf8c86 | ||
|
|
516ff17eed | ||
|
|
3b1533eb82 | ||
|
|
2e7a955ada | ||
|
|
4aa961437b | ||
|
|
0d9399a1a4 | ||
|
|
9b62cf563a | ||
|
|
7c525e2f2f | ||
|
|
63888d55fd | ||
|
|
7287ead851 | ||
|
|
6fc829f4f1 | ||
|
|
d12b0ce9be | ||
|
|
393714eb42 | ||
|
|
fe363b8a2d | ||
|
|
064ce7e6e6 | ||
|
|
af72846998 | ||
|
|
899b4f7c90 | ||
|
|
2e5b41b711 | ||
|
|
809f048b44 | ||
|
|
51c0c44a2e | ||
|
|
811d6d8c67 | ||
|
|
c1b9bf42ad | ||
|
|
cafe742cd8 | ||
|
|
ba50ec052b | ||
|
|
386f97abf8 | ||
|
|
7678b1b100 | ||
|
|
9df82a78e7 | ||
|
|
c87466ada4 | ||
|
|
135eb6763a | ||
|
|
24a0be9c3a | ||
|
|
f090bc82ae | ||
|
|
3254479ee5 | ||
|
|
6cc4266435 | ||
|
|
3e48b293b0 | ||
|
|
492b9494ec | ||
|
|
b31d2d49d6 | ||
|
|
245bd4a44d | ||
|
|
6d4a887d00 | ||
|
|
f95023f59d | ||
|
|
e69c1cf3a9 | ||
|
|
b5229f5fe1 | ||
|
|
e2ef7cfd70 | ||
|
|
6e89e69fb6 | ||
|
|
1e6ee67932 | ||
|
|
0fead83279 | ||
|
|
66952c9212 | ||
|
|
38bb9f457d | ||
|
|
3e615bb152 | ||
|
|
f83a19260f | ||
|
|
69c3c41196 | ||
|
|
6c1a4f3971 | ||
|
|
035ba899ee | ||
|
|
33cc261b62 | ||
|
|
fee6f19d5e | ||
|
|
68e8a13f73 | ||
|
|
a639c3f36d | ||
|
|
a5f10fef60 | ||
|
|
d334c04c47 | ||
|
|
7c3d9aad1d | ||
|
|
ceb81aa2bf | ||
|
|
3ee743ce1a | ||
|
|
d1ffea5432 | ||
|
|
628e73ef69 | ||
|
|
764928a492 | ||
|
|
88638b0cae | ||
|
|
51384e654b | ||
|
|
aecfaa5fd8 | ||
|
|
fc5089f7f1 | ||
|
|
25520e7d2a | ||
|
|
7ea5be0152 | ||
|
|
9a7e678bbe | ||
|
|
1f0ca70c47 | ||
|
|
93705e5baf | ||
|
|
72e8fa3679 | ||
|
|
c08ce91197 | ||
|
|
b520a0faed | ||
|
|
80837abab5 | ||
|
|
5c78135f1a | ||
|
|
386658ed37 | ||
|
|
ab8b0beb02 | ||
|
|
bc89062681 | ||
|
|
14685be92d | ||
|
|
6d565a2960 | ||
|
|
3fc247d85c | ||
|
|
cfbc4536c6 | ||
|
|
dcf825c43e | ||
|
|
8ae05d9964 | ||
|
|
f11faa87b1 | ||
|
|
bfbaabd7e8 | ||
|
|
d35d6a44fe | ||
|
|
9c7265565e | ||
|
|
293b023add | ||
|
|
bee5c1bfa6 | ||
|
|
c93a6dffc4 | ||
|
|
60dadc0088 | ||
|
|
a26c7e3d97 | ||
|
|
9e2b2cedd6 | ||
|
|
636d59659d | ||
|
|
63e8391893 | ||
|
|
2f354e4b81 | ||
|
|
b3a369d91b | ||
|
|
4406532994 | ||
|
|
8fd5176e22 | ||
|
|
e3396065c6 | ||
|
|
84600c016e | ||
|
|
64d96ab1d7 | ||
|
|
6d6720a511 | ||
|
|
17232a1909 | ||
|
|
eb02049507 | ||
|
|
ed7ad2f51b | ||
|
|
3e0202e6cb | ||
|
|
d59a68ec2e | ||
|
|
f249884907 | ||
|
|
c823d1689f | ||
|
|
b10f268e69 | ||
|
|
eff21c4f8f | ||
|
|
5537208abb | ||
|
|
53a5d3e88e | ||
|
|
20f840fd99 | ||
|
|
dc940c0608 | ||
|
|
f5c77d41f1 | ||
|
|
014c613577 | ||
|
|
d369c2c658 | ||
|
|
536868d3e5 | ||
|
|
5706738898 | ||
|
|
2947c2c7d0 | ||
|
|
18ac527629 | ||
|
|
6d80d14421 | ||
|
|
e60d8c5c20 | ||
|
|
51b4cf444a | ||
|
|
f78ec5cc90 | ||
|
|
fd6a63a76c | ||
|
|
e0f186bb94 | ||
|
|
9fadab5bb9 | ||
|
|
ea768fb560 | ||
|
|
71cb18f360 | ||
|
|
3a1694ac9a | ||
|
|
91c47b6e87 | ||
|
|
fe4e03df39 | ||
|
|
d110b9fbbf | ||
|
|
dc8c177f20 | ||
|
|
1beacd240e | ||
|
|
083394b928 | ||
|
|
6de8d9d7e0 | ||
|
|
fb4457e767 | ||
|
|
d4c1907349 | ||
|
|
c92926f3ab | ||
|
|
8f4f8f7953 | ||
|
|
5a3ea1c258 | ||
|
|
a567ec70ad | ||
|
|
e402081777 | ||
|
|
ed9c27341f | ||
|
|
56d9c9a50f | ||
|
|
d2a22f4ca0 | ||
|
|
09582fd0b1 | ||
|
|
1d5dfbfd29 | ||
|
|
044d47adad | ||
|
|
cd9465c0e7 | ||
|
|
d179c93c0b | ||
|
|
3c84eae647 | ||
|
|
53a146d0e5 | ||
|
|
57db7f0272 | ||
|
|
b820b6564f | ||
|
|
3908c42c7f | ||
|
|
1793fe40bf | ||
|
|
0857695332 | ||
|
|
a7b3bac8f8 | ||
|
|
e6f122d8b7 | ||
|
|
b2cdc2ddef | ||
|
|
94057059bb | ||
|
|
927ef17896 | ||
|
|
33f915879b | ||
|
|
98bca48359 | ||
|
|
171f763908 | ||
|
|
605d83e530 | ||
|
|
5f710b7304 | ||
|
|
1c6593fb17 | ||
|
|
33fe34d3b3 | ||
|
|
e257fec92c | ||
|
|
6a70a6c72f | ||
|
|
270d6b411a | ||
|
|
803b1e861e | ||
|
|
7300dc3d78 | ||
|
|
405d64402b | ||
|
|
fe1bff1074 | ||
|
|
2952ca1978 | ||
|
|
32dcd7747b | ||
|
|
eca5a1de15 | ||
|
|
886442ac52 | ||
|
|
8521611008 | ||
|
|
df647e4047 | ||
|
|
af26026d25 | ||
|
|
43be55f3d6 | ||
|
|
9b3cb92cb3 | ||
|
|
f2b0ce5fd0 | ||
|
|
787ca98daf | ||
|
|
71517dc454 | ||
|
|
949301d58f | ||
|
|
cf8ac4faae | ||
|
|
ad3aa124fc | ||
|
|
2326aa9f7c | ||
|
|
acfd687d74 | ||
|
|
9b9183395d | ||
|
|
45b4902b2b | ||
|
|
6056d60d32 | ||
|
|
61af248c09 | ||
|
|
5a580db1b8 | ||
|
|
a72ae49e93 | ||
|
|
40a01219cc | ||
|
|
c652390a9c | ||
|
|
0916a97526 | ||
|
|
d76ef0a1ca | ||
|
|
3a70ce1587 | ||
|
|
9f96e84378 | ||
|
|
985771beba | ||
|
|
3967633f77 | ||
|
|
ee5699c2de | ||
|
|
b8f4d9f0bb | ||
|
|
8b1de47517 | ||
|
|
c6ff514ba2 | ||
|
|
50f2d18b61 | ||
|
|
fa7ccdfc20 | ||
|
|
fe02519737 | ||
|
|
f2e9fbebea | ||
|
|
52af109b4e | ||
|
|
833e6bc3e1 | ||
|
|
9bed6fdb9a | ||
|
|
a4a867ebcd | ||
|
|
4da2f18957 | ||
|
|
4141a52d51 | ||
|
|
4dbc71d577 | ||
|
|
3b5d792562 | ||
|
|
dd7f3225c6 | ||
|
|
3477e7b8ba | ||
|
|
a3782bb459 | ||
|
|
d4003eee04 | ||
|
|
c9bbb66284 | ||
|
|
1498fa9868 | ||
|
|
26b493aa1d | ||
|
|
f9d652f849 | ||
|
|
30b0ee444f | ||
|
|
bf8341ef58 | ||
|
|
f9f1b0d98e | ||
|
|
743a4c9d87 | ||
|
|
275baa7497 | ||
|
|
ac1fb879c3 | ||
|
|
270ad56450 | ||
|
|
d155241efc | ||
|
|
d61927cbee | ||
|
|
996b68f9c6 | ||
|
|
f6913663d1 | ||
|
|
58600e13a8 | ||
|
|
54e15ad3cd | ||
|
|
777c652bf5 | ||
|
|
7fb1473e21 | ||
|
|
95db1ec84f | ||
|
|
7438973a20 | ||
|
|
fe31855860 | ||
|
|
d7bce4414d | ||
|
|
b4a81c137c | ||
|
|
a6390f58d9 | ||
|
|
ca3f4c3ec6 | ||
|
|
53bfd40a31 | ||
|
|
798be25c43 | ||
|
|
ebe1bcedd0 | ||
|
|
4731575b87 | ||
|
|
383e761372 | ||
|
|
54a95e6a4e | ||
|
|
3105ade755 | ||
|
|
f651034303 | ||
|
|
7554dc010c | ||
|
|
70a962062b | ||
|
|
5cc7f3bf2b | ||
|
|
b040e0ca13 | ||
|
|
775094356c | ||
|
|
0771b179b5 | ||
|
|
8af307e8bb | ||
|
|
e6ec3f1ce4 | ||
|
|
b4395e7585 | ||
|
|
f8acb26763 | ||
|
|
aa931a4cc5 | ||
|
|
e1cf188fbe | ||
|
|
9e57a706ae | ||
|
|
8c73e981b5 | ||
|
|
87f5007fd3 | ||
|
|
5c42bb0282 | ||
|
|
415252b770 | ||
|
|
4f43d7cde5 | ||
|
|
c819a23f83 | ||
|
|
4a00a9a80d | ||
|
|
1f5f6b251c | ||
|
|
c925b82e86 | ||
|
|
96a5d1a658 | ||
|
|
36154422f1 | ||
|
|
5b2a878f5d | ||
|
|
976d96ef35 | ||
|
|
94b31fe5f9 | ||
|
|
cd2a59ae43 | ||
|
|
e8f5ef3aa3 | ||
|
|
887fc33073 | ||
|
|
c24ba18207 | ||
|
|
5137009f5f | ||
|
|
ba6bd6a2d2 | ||
|
|
bfc16d0878 | ||
|
|
94c41e0c6d | ||
|
|
33454cc2f8 | ||
|
|
d969a483a9 | ||
|
|
a51104fd82 | ||
|
|
a536388123 | ||
|
|
c7cb4b7015 | ||
|
|
fbb1b59e34 | ||
|
|
7d7f047f55 | ||
|
|
e9e0b0f57f | ||
|
|
baf0372537 | ||
|
|
f62716a226 | ||
|
|
9b1c47f4a5 | ||
|
|
a4dd3aa488 | ||
|
|
9dbe7379cd | ||
|
|
ad353d2d1a | ||
|
|
32b2b1ace8 | ||
|
|
13710dde06 | ||
|
|
915ddd0bad | ||
|
|
52ef356c06 | ||
|
|
69481a046b | ||
|
|
157802142c | ||
|
|
54462f8d26 | ||
|
|
fcf612d22a | ||
|
|
e8eea4be25 | ||
|
|
cce9d6cf68 | ||
|
|
143f58fd68 | ||
|
|
6fe66986be | ||
|
|
68e8abecb2 | ||
|
|
bb698a351e | ||
|
|
4506ff9c93 | ||
|
|
f88a4d56c7 | ||
|
|
fbe0f584af | ||
|
|
e28f26d471 | ||
|
|
80a9a2a712 | ||
|
|
2a47cd4fd1 | ||
|
|
ce207aed39 | ||
|
|
244e3f3b29 | ||
|
|
6a44d283f9 | ||
|
|
6addce3d20 | ||
|
|
8aae101ef6 | ||
|
|
d1bbdef5c4 | ||
|
|
b78967f8be | ||
|
|
01a2f746d0 | ||
|
|
d063c2990c | ||
|
|
e31ee031cb | ||
|
|
8fc489cc59 | ||
|
|
ffb2a19963 | ||
|
|
b271c1775c | ||
|
|
f937e95b35 | ||
|
|
1613e79860 | ||
|
|
dd40b4f205 | ||
|
|
2ad9401ee6 | ||
|
|
69ade2f6e6 | ||
|
|
4006e62a82 | ||
|
|
ec88a8b157 | ||
|
|
128211ecd2 | ||
|
|
c35b5e3703 | ||
|
|
279894f9d7 | ||
|
|
ffa03e1d0d | ||
|
|
719f5c2ac6 | ||
|
|
baa3a21dd2 | ||
|
|
8cb84c2395 | ||
|
|
ee52e725d9 | ||
|
|
f3e0c31087 | ||
|
|
92ccdfc55f | ||
|
|
0528e59572 | ||
|
|
0ab3219115 | ||
|
|
724cfddb69 | ||
|
|
7fcc28a320 | ||
|
|
94051f178b | ||
|
|
8e4f339aa1 | ||
|
|
425aa69691 | ||
|
|
1c65334b91 | ||
|
|
726d04f0f4 | ||
|
|
9d1ab5a9b8 | ||
|
|
09bad79870 |
4
.github/workflows/create-release.yml
vendored
4
.github/workflows/create-release.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ github.ref }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: true
|
||||
|
||||
# Google Play APK
|
||||
- name: Upload Play APK Universal Asset
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1
.idea/.name
generated
1
.idea/.name
generated
@@ -1 +0,0 @@
|
||||
Amethyst
|
||||
40
.idea/inspectionProfiles/Project_Default.xml
generated
40
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,40 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.8.21" />
|
||||
<option name="version" value="1.9.0" />
|
||||
</component>
|
||||
</project>
|
||||
38
README.md
38
README.md
@@ -4,6 +4,11 @@
|
||||
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
|
||||
[<img src="./docs/design/obtainium.png"
|
||||
alt="Get it on Obtaininum"
|
||||
height="80">](https://github.com/ImranR98/Obtainium)
|
||||
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
|
||||
height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
|
||||
@@ -11,15 +16,15 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
alt="Get it on Google Play"
|
||||
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
- [x] Reactions (likes NIP-25, boost, reply)
|
||||
- [x] Replies and mentions (NIP-10)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [x] Reposts (NIP-18)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] View Threads
|
||||
- [x] Private Messages (NIP-04)
|
||||
@@ -36,7 +41,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
- [x] Nostr Address (NIP-05)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
@@ -56,22 +61,35 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Zap Forwarding (NIP-57)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
|
||||
- [x] Audio Tracks (zapstr.live) (Kind:31337)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [x] Generic Reposts (kind:16)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Relay Pages (NIP-11)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [x] Emoji Packs (Kind:30030)
|
||||
- [x] Personal Emoji Lists (Kind:10030)
|
||||
- [x] Classifieds (Kind:30403)
|
||||
- [x] Private Messages and Small Groups (NIP-24)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Status Event (NIP-38)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Relay Pages (NIP-11)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Workspaces
|
||||
- [ ] Expiration Support (NIP-40)
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [ ] Signing Requests (NIP-46)
|
||||
@@ -184,6 +202,12 @@ Information that you share is publicly visible to anyone reading from relays tha
|
||||
## Information Permanence ##
|
||||
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
|
||||
|
||||
# Screenshots
|
||||
|
||||
| Home Feed | Messages | Live Streams | Notifications |
|
||||
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
|
||||
|  |  |  |  |
|
||||
|
||||
# Contributing
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
|
||||
|
||||
BIN
amethyst.png
BIN
amethyst.png
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,33 +1,33 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
|
||||
id 'org.jlleitschuh.gradle.ktlint' version "11.5.1"
|
||||
id 'com.google.gms.google-services'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.vitorpamplona.amethyst'
|
||||
compileSdk 33
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 215
|
||||
versionName "0.60.2"
|
||||
targetSdk 34
|
||||
versionCode 282
|
||||
versionName "0.75.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW" )
|
||||
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Make sure all of JSON parsers work when activating these.
|
||||
//minifyEnabled true
|
||||
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
//signingConfig signingConfigs.debug
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
|
||||
minifyEnabled true
|
||||
resValue "string", "app_name", "@string/app_name_release"
|
||||
}
|
||||
debug {
|
||||
@@ -37,7 +37,7 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "channel"
|
||||
flavorDimensions = ["channel"]
|
||||
|
||||
productFlavors {
|
||||
play {
|
||||
@@ -73,7 +73,7 @@ android {
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion "1.4.7"
|
||||
kotlinCompilerExtensionVersion "1.5.1"
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
@@ -87,7 +87,8 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation project(path: ':quartz')
|
||||
implementation "androidx.core:core-ktx:$core_ktx_version"
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
@@ -96,7 +97,7 @@ dependencies {
|
||||
implementation "androidx.fragment:fragment-ktx:$fragment_version"
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:$nav_version")
|
||||
implementation "androidx.navigation:navigation-compose:$nav_version"
|
||||
|
||||
// Observe Live data as State
|
||||
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
|
||||
@@ -108,41 +109,27 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
implementation 'net.engawapg.lib:zoomable:1.4.2'
|
||||
// Zoomable images
|
||||
implementation "net.engawapg.lib:zoomable:1.5.0"
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
exclude group:'fr.acinq.secp256k1'
|
||||
exclude module: 'guava'
|
||||
exclude module: 'guava-testlib'
|
||||
}
|
||||
|
||||
// Websockets API
|
||||
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.15.2'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// link preview
|
||||
// HTML Parsing for Link Preview
|
||||
implementation 'org.jsoup:jsoup:1.16.1'
|
||||
//implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
|
||||
implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
|
||||
// important for proxy / tor
|
||||
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
|
||||
implementation "androidx.media3:media3-datasource-okhttp:${exoplayer_version}"
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
@@ -157,11 +144,8 @@ dependencies {
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
// Parses URLs from Text:
|
||||
implementation "io.github.url-detector:url-detector:0.1.23"
|
||||
|
||||
// For QR generation
|
||||
implementation 'com.google.zxing:core:3.5.1'
|
||||
implementation 'com.google.zxing:core:3.5.2'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
|
||||
// Markdown
|
||||
@@ -170,9 +154,15 @@ dependencies {
|
||||
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
|
||||
|
||||
// Markdown (With fix for full-image bleeds)
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:main-SNAPSHOT')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:main-SNAPSHOT')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:main-SNAPSHOT')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
|
||||
implementation "androidx.media3:media3-ui:$media3_version"
|
||||
implementation "androidx.media3:media3-session:$media3_version"
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation 'com.google.mlkit:language-id:17.0.4'
|
||||
@@ -181,7 +171,7 @@ dependencies {
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.1.0')
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.2.3')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
// Charts
|
||||
@@ -190,19 +180,19 @@ dependencies {
|
||||
implementation "com.patrykandpatrick.vico:views:${vico_version}"
|
||||
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
|
||||
|
||||
// immutable collections to avoid recomposition
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
|
||||
// GeoHash
|
||||
implementation 'com.github.drfonfon:android-kotlin-geohash:1.0'
|
||||
|
||||
// Waveform visualizer
|
||||
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
|
||||
|
||||
// Video compression lib
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.2.3'
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
|
||||
// Image compression lib
|
||||
implementation 'id.zelory:compressor:3.0.1'
|
||||
|
||||
// Automatic memory leak detection
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.5'
|
||||
testImplementation 'io.mockk:mockk:1.13.7'
|
||||
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"
|
||||
|
||||
73
app/proguard-rules.pro
vendored
73
app/proguard-rules.pro
vendored
@@ -5,33 +5,56 @@
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
# For the Secp256k1 library
|
||||
# preserve the line number information for debugging stack traces.
|
||||
-dontobfuscate
|
||||
-keepattributes LocalVariableTable
|
||||
-keepattributes LocalVariableTypeTable
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile
|
||||
-keepattributes LineNumberTable
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
-keepattributes MethodParameters
|
||||
-keepparameternames
|
||||
|
||||
-keep class fr.acinq.secp256k1.jni.** { *; }
|
||||
# For the NostrPostr library
|
||||
-keep class nostr.postr.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
|
||||
# Json parsing
|
||||
-keep class com.google.gson.reflect.** { *; }
|
||||
-keep class * extends com.google.gson.reflect.TypeToken
|
||||
-keep public class * implements java.lang.reflect.Type
|
||||
-keepdirectories libs
|
||||
|
||||
-keep class com.vitorpamplona.amethyst.lnurl.** { *; }
|
||||
# Keep all names
|
||||
-keepnames class ** { *; }
|
||||
|
||||
# Keep All enums
|
||||
-keep enum ** { *; }
|
||||
|
||||
# preserve access to native classses
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
|
||||
# JNA For Libsodium
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
|
||||
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
|
||||
-keep class com.sun.jna.ToNativeConverter { *; }
|
||||
-keep class com.sun.jna.NativeMapped { *; }
|
||||
-keep class com.sun.jna.CallbackReference { *; }
|
||||
-keep class com.sun.jna.ptr.IntByReference { *; }
|
||||
-keep class com.sun.jna.NativeLong { *; }
|
||||
-keep class com.sun.jna.Structure { *; }
|
||||
-keep class com.sun.jna.Structure$* { *; }
|
||||
-keep class com.sun.jna.Native$ffi_callback { *; }
|
||||
-keep class * implements com.sun.jna.Structure$* { *; }
|
||||
-keep class * implements com.sun.jna.Native$* { *; }
|
||||
-keep class com.sun.jna.Native {
|
||||
private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object);
|
||||
private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object);
|
||||
private static java.lang.Class nativeType(java.lang.Class);
|
||||
private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object);
|
||||
private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method);
|
||||
}
|
||||
|
||||
# JSON parsing
|
||||
-keep class com.vitorpamplona.quartz.crypto.** { *; }
|
||||
-keep class com.vitorpamplona.quartz.encoders.** { *; }
|
||||
-keep class com.vitorpamplona.quartz.events.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.model.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.ui.** { *; }
|
||||
|
||||
@@ -1,113 +1,78 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.FileServer
|
||||
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.NostrFilesDevServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.delay
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
@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 {
|
||||
private fun testBase(server: FileServer) {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var url: String? = null
|
||||
var error: String? = null
|
||||
|
||||
ImageUploader.account = Account(
|
||||
KeyPair()
|
||||
)
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
ImgurServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
server,
|
||||
onSuccess = { newUrl, contentType ->
|
||||
println("Uploaded $contentType to $url")
|
||||
url = newUrl
|
||||
countDownLatch.countDown()
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
error = it.message
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
)
|
||||
|
||||
delay(5000)
|
||||
countDownLatch.await()
|
||||
|
||||
Assert.assertNull(error)
|
||||
Assert.assertTrue(url?.startsWith("http") == true)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
testBase(ImgurServer())
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrBuildUpload() = runBlocking {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrBuildServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
testBase(NostrBuildServer())
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrImgUpload() = runBlocking {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrImgServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
testBase(NostrImgServer())
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrFilesDevUpload() = runBlocking {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrFilesDevServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(5000)
|
||||
testBase(NostrFilesDevServer())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -5,10 +5,10 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -29,7 +29,10 @@ class UrlUserTagTransformationTest {
|
||||
|
||||
@Test
|
||||
fun transformationText() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
val user = LocalCache.getOrCreateUser(
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey()
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
@@ -63,7 +66,10 @@ class UrlUserTagTransformationTest {
|
||||
|
||||
@Test
|
||||
fun transformationTextTwoKeys() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
val user = LocalCache.getOrCreateUser(
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey()
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
|
||||
@@ -11,17 +11,17 @@ import org.junit.runner.RunWith
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TranslationsTest {
|
||||
|
||||
fun translatePT(text: String, translateTo: String): String? {
|
||||
fun translateTo(text: String, translateTo: String): String? {
|
||||
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
|
||||
return Tasks.await(task).result
|
||||
}
|
||||
|
||||
fun assertTranslate(expected: String, input: String, translateTo: String) {
|
||||
assertEquals(null, expected, translatePT(input, translateTo))
|
||||
assertEquals(null, expected, translateTo(input, translateTo))
|
||||
}
|
||||
|
||||
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
|
||||
val translated = translatePT(input, translateTo)!!
|
||||
val translated = translateTo(input, translateTo)!!
|
||||
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
|
||||
}
|
||||
|
||||
@@ -86,4 +86,26 @@ class TranslationsTest {
|
||||
"en"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testJapaneseTranslationsOfUrl() {
|
||||
assertTranslateContains(
|
||||
"https://youtu.be/wMYFmCDy_Eg",
|
||||
"うちの会社の小さい先輩の話 第1話「うちの会社の先輩は小さくて可愛い」\n" +
|
||||
"\n" +
|
||||
"https://youtu.be/wMYFmCDy_Eg\n" +
|
||||
"\n" +
|
||||
"先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど",
|
||||
"en"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmoji() {
|
||||
assertTranslateContains(
|
||||
"https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg",
|
||||
"\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ",
|
||||
"pt"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
@Composable
|
||||
fun TranslatableRichTextViewer(
|
||||
|
||||
@@ -27,6 +27,19 @@
|
||||
<!-- To know receive notifications when the app connects/disconnects from the web -->
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<!-- Audio/Video Playback -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<!-- Keeps screen on while playing videos -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- This notification permission is needed for some phones -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- Adds Geohash to posts if active -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Old permission to access media -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -47,10 +60,11 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
tools:targetApi="33">
|
||||
tools:targetApi="34">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
@@ -91,6 +105,17 @@
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:stopWithTask="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 22 KiB |
@@ -1,11 +1,38 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.os.StrictMode.VmPolicy
|
||||
|
||||
class Amethyst : Application() {
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
VideoCache.initFileCache(instance)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectContentUriWithoutPermission()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.penaltyLog()
|
||||
// .penaltyDeath()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -4,24 +4,25 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
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
|
||||
import nostr.postr.toNpub
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
@@ -43,6 +44,7 @@ private object PrefKeys {
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val FOLLOWING_COMMUNITIES = "following_communities"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
@@ -55,21 +57,26 @@ private object PrefKeys {
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
|
||||
const val WARN_ABOUT_REPORTS = "warn_about_reports"
|
||||
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
|
||||
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
|
||||
const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images"
|
||||
const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback"
|
||||
const val THEME = "theme"
|
||||
const val PREFERRED_LANGUAGE = "preferred_Language"
|
||||
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
private val gson = GsonBuilder().create()
|
||||
|
||||
object LocalPreferences {
|
||||
private const val comma = ","
|
||||
|
||||
@@ -198,33 +205,40 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
fun allLocalAccountNPubs(): Set<String> {
|
||||
return savedAccounts().toSet()
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||
prefs.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
|
||||
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
|
||||
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
|
||||
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
|
||||
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
|
||||
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
|
||||
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
|
||||
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
||||
putString(PrefKeys.LANGUAGE_PREFS, Event.mapper.writeValueAsString(account.languagePreferences))
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||
putString(PrefKeys.REACTION_CHOICES, gson.toJson(account.reactionChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
|
||||
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name)
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, Event.mapper.writeValueAsString(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.mapper.writeValueAsString(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
|
||||
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
|
||||
putString(PrefKeys.LAST_READ_PER_ROUTE, gson.toJson(account.lastReadPerRoute))
|
||||
putString(PrefKeys.LAST_READ_PER_ROUTE, Event.mapper.writeValueAsString(account.lastReadPerRoute))
|
||||
|
||||
if (account.showSensitiveContent == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
@@ -232,6 +246,47 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
|
||||
}
|
||||
}.apply()
|
||||
|
||||
val globalPrefs = encryptedPreferences()
|
||||
globalPrefs.edit().apply {
|
||||
if (account.settings.automaticallyShowImages.prefCode == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages.prefCode!!)
|
||||
}
|
||||
|
||||
if (account.settings.automaticallyStartPlayback.prefCode == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_START_PLAYBACK)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback.prefCode!!)
|
||||
}
|
||||
if (account.settings.automaticallyShowUrlPreview.prefCode == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview.prefCode!!)
|
||||
}
|
||||
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun updateTheme(theme: Int) {
|
||||
encryptedPreferences().edit().apply {
|
||||
putInt(PrefKeys.THEME, theme)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun getTheme(): Int {
|
||||
encryptedPreferences().apply {
|
||||
return getInt(PrefKeys.THEME, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPreferredLanguage(): String {
|
||||
var language = ""
|
||||
encryptedPreferences().apply {
|
||||
language = getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
|
||||
}
|
||||
return language
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
@@ -245,41 +300,39 @@ object LocalPreferences {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
val localRelays = gson.fromJson(
|
||||
getString(PrefKeys.RELAYS, "[]"),
|
||||
object : TypeToken<Set<RelaySetupInfo>>() {}.type
|
||||
) ?: setOf<RelaySetupInfo>()
|
||||
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
|
||||
println("LocalRelays: $it")
|
||||
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
|
||||
} ?: setOf<RelaySetupInfo>()
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let {
|
||||
Event.mapper.readValue<List<Long>?>(it)
|
||||
}?.ifEmpty { listOf(500L, 1000L, 5000L) } ?: listOf(500L, 1000L, 5000L)
|
||||
|
||||
val reactionChoices = gson.fromJson<List<String>>(
|
||||
getString(PrefKeys.REACTION_CHOICES, "[]"),
|
||||
object : TypeToken<List<String>>() {}.type
|
||||
).ifEmpty { listOf("+") } ?: listOf("+")
|
||||
val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let {
|
||||
Event.mapper.readValue<List<String>?>(it)
|
||||
}?.ifEmpty { listOf("+") } ?: listOf("+")
|
||||
|
||||
val defaultZapType = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
|
||||
object : TypeToken<LnZapEvent.ZapType>() {}.type
|
||||
) ?: LnZapEvent.ZapType.PUBLIC
|
||||
val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
|
||||
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
|
||||
} ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"),
|
||||
object : TypeToken<ServersAvailable>() {}.type
|
||||
) ?: ServersAvailable.NOSTR_BUILD
|
||||
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
|
||||
ServersAvailable.values().firstOrNull() { it.name == serverName }
|
||||
} ?: ServersAvailable.NOSTR_BUILD
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
gson.fromJson(it, Nip47URI::class.java)
|
||||
Event.mapper.readValue<Nip47URI?>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
@@ -288,8 +341,7 @@ object LocalPreferences {
|
||||
|
||||
val latestContactList = try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java)
|
||||
.getRefinedEvent(true) as ContactListEvent
|
||||
Event.fromJson(it) as ContactListEvent?
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
@@ -298,10 +350,7 @@ object LocalPreferences {
|
||||
|
||||
val languagePreferences = try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
gson.fromJson(
|
||||
it,
|
||||
object : TypeToken<Map<String, String>>() {}.type
|
||||
) as Map<String, String>
|
||||
Event.mapper.readValue<Map<String, String>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
@@ -310,6 +359,7 @@ object LocalPreferences {
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
@@ -324,19 +374,39 @@ object LocalPreferences {
|
||||
|
||||
val lastReadPerRoute = try {
|
||||
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
|
||||
gson.fromJson(
|
||||
it,
|
||||
object : TypeToken<Map<String, Long>>() {}.type
|
||||
) as Map<String, Long>
|
||||
Event.mapper.readValue<Map<String, Long>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val settings = Settings()
|
||||
encryptedPreferences().apply {
|
||||
settings.automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
settings.automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
settings.automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
|
||||
}
|
||||
|
||||
val a = Account(
|
||||
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels = followingChannels,
|
||||
followingCommunities = followingCommunities,
|
||||
hiddenUsers = hiddenUsers,
|
||||
localRelays = localRelays,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
@@ -349,16 +419,19 @@ object LocalPreferences {
|
||||
defaultHomeFollowList = defaultHomeFollowList,
|
||||
defaultStoriesFollowList = defaultStoriesFollowList,
|
||||
defaultNotificationFollowList = defaultNotificationFollowList,
|
||||
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP24WarningDialog = hideNIP24WarningDialog,
|
||||
backupContactList = latestContactList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
settings = settings
|
||||
)
|
||||
|
||||
return a
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.LruCache
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.STATE_IDLE
|
||||
import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
class MultiPlayerPlaybackManager(
|
||||
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
|
||||
private val cachedPositions: VideoViewedPositionCache
|
||||
) {
|
||||
// protects from LruCache killing playing sessions
|
||||
private val playingMap = mutableMapOf<String, MediaSession>()
|
||||
|
||||
private val cache =
|
||||
object : LruCache<String, MediaSession>(10) { // up to 10 videos in the screen at the same time
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: String?,
|
||||
oldValue: MediaSession?,
|
||||
newValue: MediaSession?
|
||||
) {
|
||||
super.entryRemoved(evicted, key, oldValue, newValue)
|
||||
|
||||
if (!playingMap.contains(key)) {
|
||||
oldValue?.let {
|
||||
it.player.release()
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCallbackIntent(callbackUri: String, applicationContext: Context): PendingIntent {
|
||||
return PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun getMediaSession(id: String, uri: String, callbackUri: String?, context: Context, applicationContext: Context): MediaSession {
|
||||
val existingSession = playingMap.get(id) ?: cache.get(id)
|
||||
if (existingSession != null) return existingSession
|
||||
|
||||
val player = ExoPlayer.Builder(context).run {
|
||||
dataSourceFactory?.let { setMediaSourceFactory(it) }
|
||||
build()
|
||||
}
|
||||
|
||||
player.apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
volume = 0f
|
||||
}
|
||||
|
||||
val mediaSession = MediaSession.Builder(context, player).run {
|
||||
callbackUri?.let {
|
||||
setSessionActivity(getCallbackIntent(it, applicationContext))
|
||||
}
|
||||
setId(id)
|
||||
build()
|
||||
}
|
||||
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) {
|
||||
player.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
playingMap.put(id, mediaSession)
|
||||
} else {
|
||||
player.setWakeMode(C.WAKE_MODE_NONE)
|
||||
cache.put(id, mediaSession)
|
||||
playingMap.remove(id, mediaSession)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
STATE_IDLE -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
STATE_READY -> {
|
||||
cachedPositions.get(uri)?.let { lastPosition ->
|
||||
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
|
||||
player.seekTo(lastPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cache.put(id, mediaSession)
|
||||
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun releaseAppPlayers() {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
cache.evictAll()
|
||||
playingMap.forEach {
|
||||
it.value.player.release()
|
||||
it.value.release()
|
||||
}
|
||||
playingMap.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun playingContent(): Collection<MediaSession> {
|
||||
return playingMap.values
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
|
||||
object PlaybackClientController {
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun prepareController(
|
||||
controllerID: String,
|
||||
videoUri: String,
|
||||
callbackUri: String?,
|
||||
context: Context,
|
||||
onReady: (MediaController) -> Unit
|
||||
) {
|
||||
try {
|
||||
// creating a bundle object
|
||||
// creating a bundle object
|
||||
val bundle = Bundle()
|
||||
bundle.putString("id", controllerID)
|
||||
bundle.putString("uri", videoUri)
|
||||
bundle.putString("callbackUri", callbackUri)
|
||||
|
||||
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
val controllerFuture = MediaController
|
||||
.Builder(context, sessionTokenLocal)
|
||||
.setConnectionHints(bundle)
|
||||
.buildAsync()
|
||||
|
||||
controllerFuture.addListener(
|
||||
{
|
||||
try {
|
||||
onReady(controllerFuture.get())
|
||||
} catch (e: Exception) {
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt
Normal file
143
app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt
Normal file
@@ -0,0 +1,143 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
@UnstableApi // Extend MediaSessionService
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var videoViewedPositionCache = VideoViewedPositionCache()
|
||||
|
||||
private var managerHls: MultiPlayerPlaybackManager? = null
|
||||
private var managerProgressive: MultiPlayerPlaybackManager? = null
|
||||
private var managerLocal: MultiPlayerPlaybackManager? = null
|
||||
|
||||
fun newHslDataSource(): MediaSource.Factory {
|
||||
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
|
||||
}
|
||||
|
||||
fun newProgressiveDataSource(): MediaSource.Factory {
|
||||
return ProgressiveMediaSource.Factory(VideoCache.get(Amethyst.instance, HttpClient.getHttpClient()))
|
||||
}
|
||||
|
||||
fun lazyHlsDS(): MultiPlayerPlaybackManager {
|
||||
managerHls?.let { return it }
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerHls = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
|
||||
managerProgressive?.let { return it }
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
fun lazyLocalDS(): MultiPlayerPlaybackManager {
|
||||
managerLocal?.let { return it }
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
|
||||
managerLocal = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
// Create your Player and MediaSession in the onCreate lifecycle event
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
|
||||
}
|
||||
|
||||
private fun onProxyUpdated() {
|
||||
val toDestroyHls = managerHls
|
||||
val toDestroyProgressive = managerProgressive
|
||||
|
||||
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
|
||||
toDestroyHls?.releaseAppPlayers()
|
||||
toDestroyProgressive?.releaseAppPlayers()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
|
||||
|
||||
managerHls?.releaseAppPlayers()
|
||||
managerLocal?.releaseAppPlayers()
|
||||
managerProgressive?.releaseAppPlayers()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
|
||||
return if (fileName.startsWith("file")) {
|
||||
lazyLocalDS()
|
||||
} else if (fileName.endsWith("m3u8")) {
|
||||
lazyHlsDS()
|
||||
} else {
|
||||
lazyProgressiveDS()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
// Updates any new player ready
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
|
||||
// Overrides the notification with any player actually playing
|
||||
managerHls?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides again with playing with audio
|
||||
managerHls?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a MediaSession to link with the MediaController that is making
|
||||
// this request.
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
val id = controllerInfo.connectionHints.getString("id") ?: return null
|
||||
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
|
||||
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
|
||||
|
||||
val manager = getAppropriateMediaSessionManager(uri)
|
||||
|
||||
return manager?.getMediaSession(id, uri, callbackUri, context = this, applicationContext = applicationContext)
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,21 @@ import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.util.DebugLogger
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
@@ -23,6 +30,9 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import java.io.File
|
||||
|
||||
object ServiceManager {
|
||||
private var account: Account? = null
|
||||
@@ -49,15 +59,13 @@ object ServiceManager {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} // .logger(DebugLogger())
|
||||
}.logger(DebugLogger())
|
||||
.diskCache { SingletonDiskCache.get(context.applicationContext) }
|
||||
.okHttpClient { HttpClient.getHttpClient() }
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(context.applicationContext)
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
@@ -66,11 +74,15 @@ object ServiceManager {
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
NostrDiscoveryDataSource.account = myAccount
|
||||
ImageUploader.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
NostrDiscoveryDataSource.start()
|
||||
NostrVideoDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
@@ -85,9 +97,14 @@ object ServiceManager {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrDiscoveryDataSource.stop()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrCommunityDataSource.stop()
|
||||
NostrHashtagDataSource.stop()
|
||||
NostrGeohashDataSource.stop()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
@@ -101,11 +118,40 @@ object ServiceManager {
|
||||
fun cleanUp() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet()
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
LocalCache.pruneContactLists(accounts)
|
||||
LocalCache.pruneRepliesAndReactions(accounts)
|
||||
LocalCache.prunePastVersionsOfReplaceables()
|
||||
LocalCache.pruneExpiredEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object SingletonDiskCache {
|
||||
|
||||
private const val DIRECTORY = "image_cache"
|
||||
private var instance: DiskCache? = null
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context): DiskCache {
|
||||
return instance ?: run {
|
||||
// Create the singleton disk cache instance.
|
||||
DiskCache.Builder()
|
||||
.directory(context.safeCacheDir.resolve(DIRECTORY))
|
||||
.maxSizePercent(0.2)
|
||||
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.safeCacheDir: File
|
||||
get() {
|
||||
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
|
||||
return cacheDir.apply { mkdirs() }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
|
||||
object VideoCache {
|
||||
@UnstableApi object VideoCache {
|
||||
|
||||
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
|
||||
|
||||
@@ -20,33 +22,34 @@ object VideoCache {
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
@Synchronized
|
||||
fun init(context: Context) {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
fun initFileCache(context: Context) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
} else {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
simpleCache = SimpleCache(
|
||||
File(context.cacheDir, "exoplayer"),
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
}
|
||||
|
||||
fun get(): CacheDataSource.Factory {
|
||||
// This method should be called when proxy setting changes.
|
||||
fun renewCacheFactory(client: OkHttpClient) {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(client)
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
fun get(context: Context, client: OkHttpClient): CacheDataSource.Factory {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
initFileCache(context)
|
||||
}
|
||||
|
||||
// Renews the factory because OkHttpMight have changed.
|
||||
renewCacheFactory(client)
|
||||
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.util.LruCache
|
||||
|
||||
class VideoViewedPositionCache {
|
||||
val cachedPosition = LruCache<String, Long>(100)
|
||||
|
||||
fun add(uri: String, position: Long) {
|
||||
cachedPosition.put(uri, position)
|
||||
}
|
||||
|
||||
fun get(uri: String): Long? {
|
||||
return cachedPosition.get(uri)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,15 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
|
||||
@@ -17,7 +19,6 @@ class AntiSpamFilter {
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -44,15 +45,9 @@ class AntiSpamFilter {
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
|
||||
|
||||
// Log down offenders
|
||||
if (spamMessages.get(hash) == null) {
|
||||
spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id)))
|
||||
liveSpam.invalidateData()
|
||||
} else {
|
||||
val spammer = spamMessages.get(hash)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
logOffender(hash, event)
|
||||
|
||||
liveSpam.invalidateData()
|
||||
}
|
||||
liveSpam.invalidateData()
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -62,16 +57,31 @@ class AntiSpamFilter {
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun logOffender(hashCode: Int, event: Event) {
|
||||
if (spamMessages.get(hashCode) == null) {
|
||||
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
|
||||
} else {
|
||||
val spammer = spamMessages.get(hashCode)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
}
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
|
||||
@@ -3,28 +3,113 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Stable
|
||||
class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
class PublicChatChannel(idHex: String) : Channel(idHex) {
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String {
|
||||
return info.name ?: super.toBestDisplayName()
|
||||
}
|
||||
|
||||
override fun summary(): String? {
|
||||
return info.about
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) return super.profilePicture()
|
||||
return info.picture ?: super.profilePicture()
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info.name, info.about)
|
||||
.filter { it.contains(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
|
||||
var info: LiveActivitiesEvent? = null
|
||||
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
fun address() = address
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
|
||||
this.info = channelInfo
|
||||
super.updateChannelInfo(creator, updatedAt)
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String {
|
||||
return info?.title() ?: super.toBestDisplayName()
|
||||
}
|
||||
|
||||
override fun summary(): String? {
|
||||
return info?.summary()
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
return info?.image()?.ifBlank { null }
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info?.title(), info?.summary())
|
||||
.filter { it.contains(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
abstract class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
fun idNote() = id().toNote()
|
||||
fun idDisplayNote() = idNote().toShortenHex()
|
||||
open fun id() = Hex.decode(idHex)
|
||||
open fun idNote() = id().toNote()
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return info.name ?: idDisplayNote()
|
||||
open fun toBestDisplayName(): String {
|
||||
return idDisplayNote()
|
||||
}
|
||||
|
||||
open fun summary(): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun creatorName(): String? {
|
||||
return creator?.toBestDisplayName()
|
||||
}
|
||||
|
||||
open fun profilePicture(): String? {
|
||||
return creator?.profilePicture()
|
||||
}
|
||||
|
||||
open fun updateChannelInfo(creator: User, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
@@ -39,23 +124,7 @@ class Channel(val idHex: String) {
|
||||
notes.remove(noteHex)
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) info.picture = null
|
||||
return info.picture
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info.name, info.about)
|
||||
.filter { it.startsWith(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
abstract fun anyNameStartsWith(prefix: String): Boolean
|
||||
|
||||
// Observers line up here.
|
||||
val live: ChannelLiveData = ChannelLiveData(this)
|
||||
@@ -83,7 +152,10 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
if (hasActiveObservers()) {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
@@ -92,12 +164,20 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
|
||||
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
|
||||
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
|
||||
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
|
||||
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
|
||||
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
|
||||
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class HashtagIcon(
|
||||
val icon: Int,
|
||||
val description: String,
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toHex
|
||||
import nostr.postr.toNpub
|
||||
|
||||
/** Makes the distinction between String and Hex **/
|
||||
typealias HexKey = String
|
||||
|
||||
typealias NPubKey = String
|
||||
typealias NoteId = String
|
||||
|
||||
fun NPubKey.toDisplayKey(): String {
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun NoteId.toDisplayId(): String {
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
|
||||
|
||||
fun ByteArray.toHexKey(): HexKey {
|
||||
return toHex()
|
||||
}
|
||||
|
||||
fun HexKey.hexToByteArray(): ByteArray {
|
||||
return Hex.decode(this)
|
||||
}
|
||||
|
||||
fun HexKey.toDisplayHexKey(): String {
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
} else if (pubKeyParsed != null) {
|
||||
pubKeyParsed
|
||||
} else {
|
||||
Hex.decode(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
|
||||
return try {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex
|
||||
|
||||
if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey.toHexKey()
|
||||
} else if (pubKeyParsed != null) {
|
||||
pubKeyParsed
|
||||
} else {
|
||||
Hex.decode(key).toHexKey()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
|
||||
|
||||
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
var key = mightBeAKey
|
||||
if (key.startsWith("nostr:", true)) {
|
||||
key = key.substring("nostr:".length)
|
||||
}
|
||||
|
||||
key = key.removePrefix("@")
|
||||
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
if (key.startsWith("nsec1", true)) {
|
||||
// Converts to npub
|
||||
val pubkey = Nip19.uriToRoute(Persona(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
val noteId = Nip19.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteId, restOfWord)
|
||||
} else if (key.startsWith("nprofile", true)) {
|
||||
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
|
||||
} else if (key.startsWith("nevent1", true)) {
|
||||
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
|
||||
} else if (key.startsWith("naddr1", true)) {
|
||||
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
|
||||
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
|
||||
@@ -3,26 +3,30 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
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.firstFullCharOrEmoji
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.combineWith
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
|
||||
@Stable
|
||||
class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
@@ -30,7 +34,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
override fun toNEvent() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
override fun address() = address
|
||||
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
|
||||
override fun createdAt(): Long? {
|
||||
if (event == null) return null
|
||||
|
||||
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
|
||||
val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE
|
||||
|
||||
return minOf(publishedAt, lastCreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -42,20 +53,20 @@ open class Note(val idHex: String) {
|
||||
var replyTo: List<Note>? = null
|
||||
|
||||
// These fields are updated every time an event related to this note is received.
|
||||
var replies = setOf<Note>()
|
||||
var replies = listOf<Note>()
|
||||
private set
|
||||
var reactions = mapOf<String, Set<Note>>()
|
||||
var reactions = mapOf<String, List<Note>>()
|
||||
private set
|
||||
var boosts = setOf<Note>()
|
||||
var boosts = listOf<Note>()
|
||||
private set
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
var reports = mapOf<User, List<Note>>()
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
var relays = listOf<String>()
|
||||
private set
|
||||
|
||||
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
|
||||
@@ -64,15 +75,45 @@ open class Note(val idHex: String) {
|
||||
open fun idNote() = id().toNote()
|
||||
|
||||
open fun toNEvent(): String {
|
||||
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
val myEvent = event
|
||||
return if (myEvent is WrappedEvent) {
|
||||
val host = myEvent.host
|
||||
if (host != null) {
|
||||
Nip19.createNEvent(
|
||||
host.id,
|
||||
host.pubKey,
|
||||
host.kind(),
|
||||
relays.firstOrNull()
|
||||
)
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
fun toNostrUri(): String {
|
||||
return "nostr:${toNEvent()}"
|
||||
}
|
||||
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun channelHex(): HexKey? {
|
||||
return (event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
return if (event is ChannelMessageEvent ||
|
||||
event is ChannelMetadataEvent ||
|
||||
event is ChannelCreateEvent ||
|
||||
event is LiveActivitiesChatMessageEvent ||
|
||||
event is LiveActivitiesEvent
|
||||
) {
|
||||
(event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
|
||||
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
@@ -80,11 +121,13 @@ open class Note(val idHex: String) {
|
||||
open fun createdAt() = event?.createdAt()
|
||||
|
||||
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
|
||||
this.event = event
|
||||
this.author = author
|
||||
this.replyTo = replyTo
|
||||
if (this.event?.id() != event.id()) {
|
||||
this.event = event
|
||||
this.author = author
|
||||
this.replyTo = replyTo
|
||||
|
||||
liveSet?.metadata?.invalidateData()
|
||||
liveSet?.metadata?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
@@ -95,17 +138,26 @@ open class Note(val idHex: String) {
|
||||
/**
|
||||
* This method caches signatures during each execution to avoid recalculation in longer threads
|
||||
*/
|
||||
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
|
||||
fun replyLevelSignature(
|
||||
eventsToConsider: Set<Note>,
|
||||
cachedSignatures: MutableMap<Note, String>
|
||||
): String {
|
||||
val replyTo = replyTo
|
||||
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
|
||||
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
}
|
||||
|
||||
return replyTo
|
||||
.map {
|
||||
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
val mySignature = (
|
||||
replyTo
|
||||
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
|
||||
.map {
|
||||
cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxByOrNull { it.length }?.removeSuffix(";") ?: ""
|
||||
) + "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
|
||||
cachedSignatures[this] = mySignature
|
||||
return mySignature
|
||||
}
|
||||
|
||||
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
|
||||
@@ -127,26 +179,63 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun removeReply(note: Note) {
|
||||
replies = replies - note
|
||||
liveSet?.replies?.invalidateData()
|
||||
if (note in replies) {
|
||||
replies = replies - note
|
||||
liveSet?.replies?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeBoost(note: Note) {
|
||||
boosts = boosts - note
|
||||
liveSet?.boosts?.invalidateData()
|
||||
if (note in boosts) {
|
||||
boosts = boosts - note
|
||||
liveSet?.boosts?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllChildNotes(): List<Note> {
|
||||
val toBeRemoved = replies +
|
||||
reactions.values.flatten() +
|
||||
boosts +
|
||||
reports.values.flatten() +
|
||||
zaps.keys +
|
||||
zaps.values.filterNotNull() +
|
||||
zapPayments.keys +
|
||||
zapPayments.values.filterNotNull()
|
||||
|
||||
replies = listOf<Note>()
|
||||
reactions = mapOf<String, List<Note>>()
|
||||
boosts = listOf<Note>()
|
||||
reports = mapOf<User, List<Note>>()
|
||||
zaps = mapOf<Note, Note?>()
|
||||
zapPayments = mapOf<Note, Note?>()
|
||||
relays = listOf<String>()
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
|
||||
liveSet?.replies?.invalidateData()
|
||||
liveSet?.reactions?.invalidateData()
|
||||
liveSet?.boosts?.invalidateData()
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.zaps?.invalidateData()
|
||||
|
||||
return toBeRemoved
|
||||
}
|
||||
|
||||
fun removeReaction(note: Note) {
|
||||
val reaction = note.event?.content() ?: "+"
|
||||
val tags = note.event?.tags() ?: emptyList()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
|
||||
reactions[reaction]?.let {
|
||||
val newList = it.minus(note)
|
||||
if (newList.isEmpty()) {
|
||||
reactions = reactions.minus(reaction)
|
||||
} else {
|
||||
reactions = reactions + Pair(reaction, newList)
|
||||
}
|
||||
if (note in it) {
|
||||
val newList = it.minus(note)
|
||||
if (newList.isEmpty()) {
|
||||
reactions = reactions.minus(reaction)
|
||||
} else {
|
||||
reactions = reactions + Pair(reaction, newList)
|
||||
}
|
||||
|
||||
liveSet?.reactions?.invalidateData()
|
||||
liveSet?.reactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,8 +256,7 @@ open class Note(val idHex: String) {
|
||||
zaps = zaps.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zaps.containsValue(note)) {
|
||||
val toRemove = zaps.filterValues { it == note }
|
||||
zaps = zaps.minus(toRemove.keys)
|
||||
zaps = zaps.filterValues { it != note }
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -192,34 +280,67 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZapPayment(zapPaymentRequest: Note, zapPayment: Note?): Boolean {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
val reaction = note.event?.content() ?: "+"
|
||||
val tags = note.event?.tags() ?: emptyList()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction !in reactions.keys) {
|
||||
reactions = reactions + Pair(reaction, setOf(note))
|
||||
reactions = reactions + Pair(reaction, listOf(note))
|
||||
liveSet?.reactions?.invalidateData()
|
||||
} else if (reactions[reaction]?.contains(note) == false) {
|
||||
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
|
||||
@@ -231,7 +352,7 @@ open class Note(val idHex: String) {
|
||||
val author = note.author ?: return
|
||||
|
||||
if (author !in reports.keys) {
|
||||
reports = reports + Pair(author, setOf(note))
|
||||
reports = reports + Pair(author, listOf(note))
|
||||
liveSet?.reports?.invalidateData()
|
||||
} else if (reports[author]?.contains(note) == false) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
@@ -261,18 +382,71 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): String? {
|
||||
return reactions.filter {
|
||||
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
}.keys.firstOrNull()
|
||||
fun publicZapAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.mapNotNull {
|
||||
it.key.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun publicZapAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.mapNotNull {
|
||||
it.key.author?.pubkeyHex
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun reactionAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return reactions.values.map {
|
||||
it.mapNotNull { it.author }
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
fun reactionAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return reactions.values.map {
|
||||
it.mapNotNull { it.author?.pubkeyHex }
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
fun replyAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return replies.mapNotNull {
|
||||
it.author?.pubkeyHex
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun replyAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return replies.mapNotNull {
|
||||
it.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun boostAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return boosts.mapNotNull {
|
||||
it.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun getReactionBy(user: User): String? {
|
||||
return reactions.firstNotNullOfOrNull {
|
||||
if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
|
||||
it.key
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isBoostedBy(user: User): Boolean {
|
||||
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
return reports[user] ?: emptySet()
|
||||
fun hasReportsBy(user: User): Boolean {
|
||||
return reports[user]?.isNotEmpty() ?: false
|
||||
}
|
||||
|
||||
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
|
||||
@@ -289,10 +463,6 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun countReactions(): Int {
|
||||
return reactions.values.sumOf { it.size }
|
||||
}
|
||||
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
@@ -363,7 +533,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun hasAnyReports(): Boolean {
|
||||
val dayAgo = Date().time / 1000 - 24 * 60 * 60
|
||||
val dayAgo = TimeUtils.oneDayAgo()
|
||||
return reports.isNotEmpty() ||
|
||||
(
|
||||
author?.reports?.values?.any {
|
||||
@@ -373,7 +543,14 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun isNewThread(): Boolean {
|
||||
return event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo?.size == 0
|
||||
return (
|
||||
event is RepostEvent ||
|
||||
event is GenericRepostEvent ||
|
||||
replyTo == null ||
|
||||
replyTo?.size == 0
|
||||
) &&
|
||||
event !is ChannelMessageEvent &&
|
||||
event !is LiveActivitiesChatMessageEvent
|
||||
}
|
||||
|
||||
fun hasZapped(loggedIn: User): Boolean {
|
||||
@@ -385,7 +562,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User, content: String): List<Note> {
|
||||
return reactions[content]?.filter { it.author == loggedIn && it.event?.content() == content } ?: emptyList()
|
||||
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User): List<String> {
|
||||
@@ -393,8 +570,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
|
||||
val currentTime = Date().time / 1000
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5) } != null // 5 minute protection
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() } != null // 5 minute protection
|
||||
}
|
||||
|
||||
fun boostedBy(loggedIn: User): List<Note> {
|
||||
@@ -430,13 +606,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
replyTo = null
|
||||
replies = emptySet()
|
||||
replies = emptyList()
|
||||
reactions = emptyMap()
|
||||
boosts = emptySet()
|
||||
boosts = emptyList()
|
||||
reports = emptyMap()
|
||||
zaps = emptyMap()
|
||||
}
|
||||
|
||||
fun clearEOSE() {
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
}
|
||||
|
||||
var liveSet: NoteLiveSet? = null
|
||||
|
||||
fun live(): NoteLiveSet {
|
||||
@@ -448,15 +628,40 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
|
||||
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
|
||||
if (event == null) return false
|
||||
|
||||
val isBoostedNoteHidden = if (event is GenericRepostEvent || event is RepostEvent || event is CommunityPostApprovalEvent) {
|
||||
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val isSensitive = event?.isSensitive() ?: false
|
||||
return isBoostedNoteHidden ||
|
||||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
|
||||
accountChoices.spammers.contains(author?.pubkeyHex) ||
|
||||
(isSensitive && accountChoices.showSensitiveContent == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NoteLiveSet(u: Note) {
|
||||
// Observers line up here.
|
||||
val metadata: NoteLiveData = NoteLiveData(u)
|
||||
|
||||
val authorChanges = metadata.map {
|
||||
it.note.author
|
||||
}
|
||||
val hasEvent = metadata.map {
|
||||
it.note.event != null
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val reactions: NoteLiveData = NoteLiveData(u)
|
||||
val boosts: NoteLiveData = NoteLiveData(u)
|
||||
val replies: NoteLiveData = NoteLiveData(u)
|
||||
@@ -464,6 +669,24 @@ class NoteLiveSet(u: Note) {
|
||||
val relays: NoteLiveData = NoteLiveData(u)
|
||||
val zaps: NoteLiveData = NoteLiveData(u)
|
||||
|
||||
val hasReactions = zaps.combineWith(boosts, reactions) { zapState, boostState, reactionState ->
|
||||
zapState?.note?.zaps?.isNotEmpty() ?: false ||
|
||||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
|
||||
reactionState?.note?.reactions?.isNotEmpty() ?: false
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val replyCount = replies.map {
|
||||
it.note.replies.size
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val reactionCount = reactions.map {
|
||||
it.note.reactions.values.sumOf { it.size }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val boostCount = boosts.map {
|
||||
it.note.boosts.size
|
||||
}.distinctUntilChanged()
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.hasObservers() ||
|
||||
reactions.hasObservers() ||
|
||||
@@ -473,17 +696,37 @@ class NoteLiveSet(u: Note) {
|
||||
relays.hasObservers() ||
|
||||
zaps.hasObservers()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
metadata.destroy()
|
||||
reactions.destroy()
|
||||
boosts.destroy()
|
||||
replies.destroy()
|
||||
reports.destroy()
|
||||
relays.destroy()
|
||||
zaps.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasObservers()) return
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
|
||||
class ParticipantListBuilder {
|
||||
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
|
||||
baseNote.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
|
||||
// Breaks these searchers down to avoid the memory use of creating multiple lists
|
||||
baseNote.replies.forEach { reply ->
|
||||
reply.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.boosts.forEach { boost ->
|
||||
boost.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.zaps.forEach { zapPair ->
|
||||
zapPair.key.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.reactions.forEach { reactionSet ->
|
||||
reactionSet.value.forEach { reaction ->
|
||||
reaction.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun followsThatParticipateOnDirect(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
|
||||
if (baseNote == null) return mutableSetOf()
|
||||
|
||||
val set = mutableSetOf<User>()
|
||||
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set)
|
||||
return set
|
||||
}
|
||||
|
||||
fun followsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
|
||||
if (baseNote == null) return mutableSetOf()
|
||||
|
||||
val mySet = mutableSetOf<User>()
|
||||
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
|
||||
|
||||
baseNote.replies.forEach {
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
|
||||
baseNote.boosts.forEach {
|
||||
it.replyTo?.forEach {
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
}
|
||||
|
||||
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
|
||||
return mySet
|
||||
}
|
||||
|
||||
fun countFollowsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Int {
|
||||
if (baseNote == null) return 0
|
||||
|
||||
val list = followsThatParticipateOn(baseNote, followingSet)
|
||||
|
||||
return list.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
|
||||
@Stable
|
||||
class RelayInformation(
|
||||
val id: String?,
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
val pubkey: String?,
|
||||
val contact: String?,
|
||||
val supported_nips: List<Int>?,
|
||||
val supported_nip_extensions: List<String>?,
|
||||
val software: String?,
|
||||
val version: String?,
|
||||
val limitation: RelayInformationLimitation?,
|
||||
val relay_countries: List<String>?,
|
||||
val language_tags: List<String>?,
|
||||
val tags: List<String>?,
|
||||
val posting_policy: String?,
|
||||
val payments_url: String?,
|
||||
val fees: RelayInformationFees?
|
||||
) {
|
||||
companion object {
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
fun fromJson(json: String): RelayInformation = mapper.readValue(json, RelayInformation::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class RelayInformationFee(
|
||||
val amount: Int?,
|
||||
val unit: String?,
|
||||
val period: Int?,
|
||||
val kinds: List<Int>?
|
||||
)
|
||||
|
||||
class RelayInformationFees(
|
||||
val admission: List<RelayInformationFee>?,
|
||||
val subscription: List<RelayInformationFee>?,
|
||||
val publication: List<RelayInformationFee>?,
|
||||
val retention: List<RelayInformationFee>?
|
||||
)
|
||||
|
||||
class RelayInformationLimitation(
|
||||
val max_message_length: Int?,
|
||||
val max_subscriptions: Int?,
|
||||
val max_filters: Int?,
|
||||
val max_limit: Int?,
|
||||
val max_subid_length: Int?,
|
||||
val min_prefix: Int?,
|
||||
val max_event_tags: Int?,
|
||||
val max_content_length: Int?,
|
||||
val min_pow_difficulty: Int?,
|
||||
val auth_required: Boolean?,
|
||||
val payment_required: Boolean?
|
||||
)
|
||||
@@ -12,5 +12,6 @@ data class RelaySetupInfo(
|
||||
val downloadCountInBytes: Int = 0,
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
enum class ServersAvailable {
|
||||
// IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
NOSTRFILES_DEV,
|
||||
NOSTRCHECK_ME,
|
||||
|
||||
// IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NOSTRFILES_DEV_NIP_94,
|
||||
NOSTRCHECK_ME_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Stable
|
||||
class Settings(
|
||||
var preferredLanguage: String? = null,
|
||||
var automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
var automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
var automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS
|
||||
)
|
||||
|
||||
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
|
||||
ALWAYS(null, 0, R.string.connectivity_type_always),
|
||||
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only),
|
||||
NEVER(false, 2, R.string.connectivity_type_never)
|
||||
}
|
||||
|
||||
fun parseConnectivityType(code: Boolean?): ConnectivityType {
|
||||
return when (code) {
|
||||
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
|
||||
ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY
|
||||
ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER
|
||||
else -> {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseConnectivityType(screenCode: Int): ConnectivityType {
|
||||
return when (screenCode) {
|
||||
ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS
|
||||
ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY
|
||||
ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER
|
||||
else -> {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import kotlin.time.ExperimentalTime
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class ThreadAssembler {
|
||||
@@ -17,9 +15,14 @@ class ThreadAssembler {
|
||||
testedNotes.add(note)
|
||||
|
||||
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
if (markedAsRoot != null) {
|
||||
// Check to ssee if there is an error in the tag and the root has replies
|
||||
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
|
||||
return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
}
|
||||
}
|
||||
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
// recursive
|
||||
@@ -38,21 +41,11 @@ class ThreadAssembler {
|
||||
return null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val (result, elapsed) = measureTimedValue {
|
||||
val note = if (noteId.contains(":")) {
|
||||
val aTag = ATag.parse(noteId, null)
|
||||
if (aTag != null) {
|
||||
LocalCache.getOrCreateAddressableNote(aTag)
|
||||
} else {
|
||||
return emptySet()
|
||||
}
|
||||
} else {
|
||||
LocalCache.getOrCreateNote(noteId)
|
||||
}
|
||||
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = mutableSetOf<Note>()
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
|
||||
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Stable
|
||||
object UrlCachedPreviewer {
|
||||
var cache = LruCache<String, UrlPreviewState>(100)
|
||||
private set
|
||||
|
||||
fun previewInfo(url: String, onReady: (UrlPreviewState) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
suspend fun previewInfo(
|
||||
url: String,
|
||||
onReady: suspend (UrlPreviewState) -> Unit
|
||||
) = withContext(Dispatchers.IO) {
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
BahaUrlPreview(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
override suspend fun onComplete(urlInfo: UrlInfoItem) = withContext(Dispatchers.IO) {
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +44,10 @@ object UrlCachedPreviewer {
|
||||
onReady(state)
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
override suspend fun onFailed(throwable: Throwable) = withContext(Dispatchers.IO) {
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
|
||||
@@ -3,21 +3,29 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
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 com.vitorpamplona.quartz.encoders.Bech32
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.toNpub
|
||||
import java.math.BigDecimal
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@@ -30,9 +38,6 @@ class User(val pubkeyHex: String) {
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestBookmarkList: BookmarkListEvent? = null
|
||||
|
||||
var notes = setOf<Note>()
|
||||
private set
|
||||
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
@@ -44,18 +49,33 @@ class User(val pubkeyHex: String) {
|
||||
var relaysBeingUsed = mapOf<String, RelayInfo>()
|
||||
private set
|
||||
|
||||
var privateChatrooms = mapOf<User, Chatroom>()
|
||||
var privateChatrooms = mapOf<ChatroomKey, Chatroom>()
|
||||
private set
|
||||
|
||||
var acceptedBadges: AddressableNote? = null
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
fun toNostrUri() = "nostr:${pubkeyNpub()}"
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
|
||||
fun toBestShortFirstName(): String {
|
||||
val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex()
|
||||
|
||||
val names = fullName.split(' ')
|
||||
|
||||
val firstName = if (names[0].length <= 3) {
|
||||
// too short. Remove Dr.
|
||||
"${names[0]} ${names.getOrNull(1) ?: ""}"
|
||||
} else {
|
||||
names[0]
|
||||
}
|
||||
|
||||
return firstName
|
||||
}
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
|
||||
}
|
||||
@@ -105,21 +125,6 @@ class User(val pubkeyHex: String) {
|
||||
liveSet?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
if (note !in notes) {
|
||||
notes = notes + note
|
||||
// No need for Listener yet
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
notes = notes - note
|
||||
}
|
||||
|
||||
fun clearNotes() {
|
||||
notes = setOf<Note>()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
val author = note.author ?: return
|
||||
|
||||
@@ -143,11 +148,6 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAcceptedBadges(note: AddressableNote) {
|
||||
acceptedBadges = note
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
@@ -158,6 +158,16 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZap(zapRequestOrZapEvent: Note) {
|
||||
if (zapRequestOrZapEvent in zaps.keys) {
|
||||
zaps = zaps.minus(zapRequestOrZapEvent)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapRequestOrZapEvent in zaps.values) {
|
||||
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
@@ -185,34 +195,51 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
|
||||
checkNotInMainThread()
|
||||
|
||||
return privateChatrooms[user] ?: run {
|
||||
val privateChatroom = Chatroom(setOf<Note>())
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
return privateChatrooms[key] ?: run {
|
||||
val privateChatroom = Chatroom()
|
||||
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
|
||||
privateChatroom
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
|
||||
return getOrCreatePrivateChatroom(key)
|
||||
}
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom {
|
||||
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
|
||||
}
|
||||
|
||||
fun addMessage(room: ChatroomKey, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(room)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun createChatroom(withKey: ChatroomKey) {
|
||||
getOrCreatePrivateChatroom(withKey)
|
||||
}
|
||||
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
|
||||
privateChatroom.removeMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -235,6 +262,7 @@ class User(val pubkeyHex: String) {
|
||||
info = newUserInfo
|
||||
info?.latestMetadata = latestMetadata
|
||||
info?.updatedMetadataAt = latestMetadata.createdAt
|
||||
info?.tags = latestMetadata.tags.toImmutableListOfLists()
|
||||
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
|
||||
try {
|
||||
@@ -269,6 +297,12 @@ class User(val pubkeyHex: String) {
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingGeohashCached(geoTag: String): Boolean {
|
||||
return latestContactList?.verifiedFollowGeohashSet?.let {
|
||||
return geoTag.lowercase() in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingCached(user: User): Boolean {
|
||||
return latestContactList?.verifiedFollowKeySet?.let {
|
||||
return user.pubkeyHex in it
|
||||
@@ -297,6 +331,14 @@ class User(val pubkeyHex: String) {
|
||||
return latestContactList?.verifiedFollowTagSet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingGeohashSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowGeohashSet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingCommunitiesSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowCommunitySet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowCount(): Int? {
|
||||
return latestContactList?.verifiedFollowKeySet?.size
|
||||
}
|
||||
@@ -305,8 +347,8 @@ class User(val pubkeyHex: String) {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = privateChatrooms[user] ?: return false
|
||||
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
|
||||
val messagesToUser = privateChatrooms[key] ?: return false
|
||||
|
||||
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
|
||||
}
|
||||
@@ -332,11 +374,13 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserLiveSet(u: User) {
|
||||
// UI Observers line up here.
|
||||
val follows: UserLiveData = UserLiveData(u)
|
||||
@@ -347,8 +391,20 @@ class UserLiveSet(u: User) {
|
||||
val relayInfo: UserLiveData = UserLiveData(u)
|
||||
val metadata: UserLiveData = UserLiveData(u)
|
||||
val zaps: UserLiveData = UserLiveData(u)
|
||||
val badges: UserLiveData = UserLiveData(u)
|
||||
val bookmarks: UserLiveData = UserLiveData(u)
|
||||
val statuses: UserLiveData = UserLiveData(u)
|
||||
|
||||
val profilePictureChanges = metadata.map {
|
||||
it.user.profilePicture()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val nip05Changes = metadata.map {
|
||||
it.user.nip05()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val userMetadataInfo = metadata.map {
|
||||
it.user.info
|
||||
}.distinctUntilChanged()
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers() ||
|
||||
@@ -359,9 +415,22 @@ class UserLiveSet(u: User) {
|
||||
relayInfo.hasObservers() ||
|
||||
metadata.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
badges.hasObservers() ||
|
||||
statuses.hasObservers() ||
|
||||
bookmarks.hasObservers()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
follows.destroy()
|
||||
followers.destroy()
|
||||
reports.destroy()
|
||||
messages.destroy()
|
||||
relays.destroy()
|
||||
relayInfo.destroy()
|
||||
metadata.destroy()
|
||||
zaps.destroy()
|
||||
bookmarks.destroy()
|
||||
statuses.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@@ -371,55 +440,78 @@ data class RelayInfo(
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
data class Chatroom(var roomMessages: Set<Note>)
|
||||
|
||||
@Stable
|
||||
class UserMetadata {
|
||||
var name: String? = null
|
||||
var username: String? = null
|
||||
var display_name: String? = null
|
||||
var displayName: String? = null
|
||||
var picture: String? = null
|
||||
var banner: String? = null
|
||||
var website: String? = null
|
||||
var about: String? = null
|
||||
class Chatroom() {
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
var subject: String? = null
|
||||
var subjectCreatedAt: Long? = null
|
||||
|
||||
var nip05: String? = null
|
||||
var nip05Verified: Boolean = false
|
||||
var nip05LastVerificationTime: Long? = 0
|
||||
@Synchronized
|
||||
fun addMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
var domain: String? = null
|
||||
var lud06: String? = null
|
||||
var lud16: String? = null
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
var publish: String? = null
|
||||
var iris: String? = null
|
||||
var main_relay: String? = null
|
||||
var twitter: String? = null
|
||||
val newSubject = msg.event?.subject()
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
|
||||
fun anyName(): String? {
|
||||
return display_name ?: displayName ?: name ?: username
|
||||
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
|
||||
subject = newSubject
|
||||
subjectCreatedAt = msg.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
|
||||
.any { it.contains(prefix, true) }
|
||||
@Synchronized
|
||||
fun removeMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
|
||||
subject = it.event?.subject()
|
||||
subjectCreatedAt = it.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddress(): String? {
|
||||
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
|
||||
fun senderIntersects(keySet: Set<HexKey>): Boolean {
|
||||
return roomMessages.any { it.author?.pubkeyHex in keySet }
|
||||
}
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
|
||||
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
|
||||
// Recent messages, keep last 100
|
||||
sorted.take(100).toSet()
|
||||
} else {
|
||||
// Old messages, keep the last one.
|
||||
sorted.take(1).toSet()
|
||||
} + sorted.filter { it.liveSet?.isInUse() ?: false }
|
||||
|
||||
val toRemove = roomMessages.minus(toKeep)
|
||||
roomMessages = toKeep
|
||||
return toRemove
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasObservers()) return
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ object BlurHashDecoder {
|
||||
* By default it is true.
|
||||
*/
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
@@ -14,6 +15,7 @@ import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Stable
|
||||
class BlurHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import android.util.Patterns
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
data class RichTextViewerState(
|
||||
val urlSet: ImmutableSet<String>,
|
||||
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
|
||||
val imageList: ImmutableList<ZoomableUrlContent>,
|
||||
val customEmoji: ImmutableMap<String, String>,
|
||||
val paragraphs: ImmutableList<ParagraphState>
|
||||
)
|
||||
|
||||
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
|
||||
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(200)
|
||||
|
||||
fun parseText(content: String, tags: ImmutableListOfLists<String>): RichTextViewerState {
|
||||
return if (richTextCache[content] != null) {
|
||||
richTextCache[content]
|
||||
} else {
|
||||
val newUrls = RichTextParser().parseText(content, tags)
|
||||
richTextCache.put(content, newUrls)
|
||||
newUrls
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
|
||||
// Android9 seems to have an issue starting this regex.
|
||||
val noProtocolUrlValidator = try {
|
||||
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
|
||||
} catch (e: Exception) {
|
||||
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
}
|
||||
|
||||
val HTTPRegex = "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
class RichTextParser() {
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>
|
||||
): RichTextViewerState {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
|
||||
val urlSet = urls.mapNotNullTo(LinkedHashSet(urls.size)) {
|
||||
// removes e-mails
|
||||
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
|
||||
null
|
||||
} else if (isNumber(it.originalUrl)) {
|
||||
null
|
||||
} else if (it.originalUrl.contains("。")) {
|
||||
null
|
||||
} else {
|
||||
if (HTTPRegex.matches(it.originalUrl)) {
|
||||
it.originalUrl
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlImage(fullUrl)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlVideo(fullUrl)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
val emojiMap =
|
||||
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
|
||||
|
||||
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
|
||||
|
||||
return RichTextViewerState(
|
||||
urlSet.toImmutableSet(),
|
||||
imagesForPager.toImmutableMap(),
|
||||
imageList.toImmutableList(),
|
||||
emojiMap.toImmutableMap(),
|
||||
segments
|
||||
)
|
||||
}
|
||||
|
||||
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
|
||||
var paragraphSegments = persistentListOf<ParagraphState>()
|
||||
|
||||
content.split('\n').forEach { paragraph ->
|
||||
var segments = persistentListOf<Segment>()
|
||||
var isDirty = false
|
||||
|
||||
val isRTL = isArabic(paragraph)
|
||||
|
||||
val wordList = paragraph.trimEnd().split(' ')
|
||||
wordList.forEach { word ->
|
||||
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
|
||||
if (wordSegment !is RegularTextSegment) {
|
||||
isDirty = true
|
||||
}
|
||||
segments = segments.add(wordSegment)
|
||||
}
|
||||
|
||||
val newSegments = if (isDirty) {
|
||||
ParagraphState(segments, isRTL)
|
||||
} else {
|
||||
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
|
||||
}
|
||||
|
||||
paragraphSegments = paragraphSegments.add(newSegments)
|
||||
}
|
||||
|
||||
return paragraphSegments
|
||||
}
|
||||
|
||||
fun isNumber(word: String): Boolean {
|
||||
return numberPattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
fun isDate(word: String): Boolean {
|
||||
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
private fun isArabic(text: String): Boolean {
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
}
|
||||
|
||||
private fun wordIdentifier(word: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): Segment {
|
||||
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
|
||||
val phoneMatcher = Patterns.PHONE.matcher(word)
|
||||
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
|
||||
|
||||
return if (word.isEmpty()) {
|
||||
RegularTextSegment(word)
|
||||
} else if (images.contains(word)) {
|
||||
ImageSegment(word)
|
||||
} else if (urls.contains(word)) {
|
||||
LinkSegment(word)
|
||||
} else if (emojis.any { word.contains(it.key) }) {
|
||||
EmojiSegment(word)
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
InvoiceSegment(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
WithdrawSegment(word)
|
||||
} else if (word.startsWith("cashuA", true)) {
|
||||
CashuSegment(word)
|
||||
} else if (emailMatcher.matches()) {
|
||||
EmailSegment(word)
|
||||
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
|
||||
PhoneSegment(word)
|
||||
} else if (startsWithNIP19Scheme(word)) {
|
||||
BechSegment(word)
|
||||
} else if (word.startsWith("#")) {
|
||||
parseHash(word, tags)
|
||||
} else if (word.contains(".") && schemelessMatcher.find()) {
|
||||
val url = schemelessMatcher.group(1) // url
|
||||
val additionalChars = schemelessMatcher.group(4) // additional chars
|
||||
val pattern = """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""".toRegex(RegexOption.IGNORE_CASE)
|
||||
if (pattern.find(word) != null) {
|
||||
SchemelessUrlSegment(word, url, additionalChars)
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHash(word: String, tags: ImmutableListOfLists<String>): Segment {
|
||||
// First #[n]
|
||||
|
||||
val matcher = tagIndex.matcher(word)
|
||||
try {
|
||||
if (matcher.find()) {
|
||||
val index = matcher.group(1)?.toInt()
|
||||
val suffix = matcher.group(2)
|
||||
|
||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
||||
val tag = tags.lists[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
return HashIndexUserSegment(word, tag[1], suffix)
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
return HashIndexEventSegment(word, tag[1], suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
}
|
||||
|
||||
// Second #Amethyst
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
try {
|
||||
if (hashtagMatcher.find()) {
|
||||
val hashtag = hashtagMatcher.group(1)
|
||||
if (hashtag != null) {
|
||||
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
}
|
||||
|
||||
return RegularTextSegment(word)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
|
||||
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
open class Segment(val segmentText: String)
|
||||
|
||||
@Immutable
|
||||
class ImageSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class LinkSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class EmojiSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class InvoiceSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class WithdrawSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class CashuSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class EmailSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PhoneSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class BechSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class HashIndexUserSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashIndexEventSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class RegularTextSegment(segment: String) : Segment(segment)
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.Base64
|
||||
|
||||
@Immutable
|
||||
data class CashuToken(
|
||||
val token: String,
|
||||
val mint: String,
|
||||
val totalAmount: Long,
|
||||
val fees: Int,
|
||||
val redeemInvoiceAmount: Long,
|
||||
val proofs: JsonNode
|
||||
)
|
||||
|
||||
class CashuProcessor {
|
||||
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val base64token = cashuToken.replace("cashuA", "")
|
||||
val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token)))
|
||||
val token = cashu.get("token").get(0)
|
||||
val proofs = token.get("proofs")
|
||||
val mint = token.get("mint").asText()
|
||||
|
||||
var totalAmount = 0L
|
||||
for (proof in proofs) {
|
||||
totalAmount += proof.get("amount").asLong()
|
||||
}
|
||||
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
|
||||
val redeemInvoiceAmount = totalAmount - fees
|
||||
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
|
||||
} catch (e: Exception) {
|
||||
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
runCatching {
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
|
||||
message = "Redeem Cashu",
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, onSuccess, onError)
|
||||
},
|
||||
onProgress = {
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
val jsonObject = factory.objectNode()
|
||||
jsonObject.put("proofs", token.proofs)
|
||||
jsonObject.put("pr", invoice)
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
val body = it.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
|
||||
val successful = tree?.get("paid")?.asText() == "true"
|
||||
|
||||
if (successful) {
|
||||
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
|
||||
} else {
|
||||
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Token melt failure: " + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
fun String.isUTF16Char(pos: Int): Boolean {
|
||||
return Character.charCount(this.codePointAt(pos)) == 2
|
||||
}
|
||||
|
||||
fun String.firstFullCharOld(): String {
|
||||
return when (this.length) {
|
||||
0, 1 -> return this
|
||||
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
|
||||
else -> {
|
||||
val first = isUTF16Char(0)
|
||||
val second = isUTF16Char(2)
|
||||
if (first && second) {
|
||||
this.take(4)
|
||||
} else if (first) {
|
||||
this.take(2)
|
||||
} else {
|
||||
this.take(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.firstFullChar(): String {
|
||||
var isInJoin = false
|
||||
var hasHadSecondChance = false
|
||||
var start = 0
|
||||
var previousCharLength = 0
|
||||
var next: Int
|
||||
var codePoint: Int
|
||||
|
||||
var i = 0
|
||||
|
||||
while (i < this.length) {
|
||||
codePoint = codePointAt(i)
|
||||
|
||||
// Skips if it starts with the join char 0x200D
|
||||
if (codePoint == 0x200D && previousCharLength == 0) {
|
||||
next = offsetByCodePoints(i, 1)
|
||||
start = next
|
||||
} else {
|
||||
// If join, searches for the next char
|
||||
if (codePoint == 0xFE0F) {
|
||||
} else if (codePoint == 0x200D) {
|
||||
isInJoin = true
|
||||
} else {
|
||||
// stops when two chars are not joined together
|
||||
if (previousCharLength > 0 && !isInJoin) {
|
||||
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
|
||||
break
|
||||
} else {
|
||||
hasHadSecondChance = true
|
||||
}
|
||||
} else {
|
||||
hasHadSecondChance = false
|
||||
}
|
||||
|
||||
isInJoin = false
|
||||
}
|
||||
|
||||
// next char to evaluate
|
||||
next = offsetByCodePoints(i, 1)
|
||||
previousCharLength += (next - i)
|
||||
}
|
||||
|
||||
i = next
|
||||
}
|
||||
|
||||
// if ends in join, then seachers backwards until a char is found.
|
||||
if (isInJoin) {
|
||||
i = previousCharLength - 1
|
||||
while (i > 0) {
|
||||
if (this[i].code == 0x200D) {
|
||||
previousCharLength -= 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
i -= 1
|
||||
}
|
||||
}
|
||||
|
||||
return substring(start, start + previousCharLength)
|
||||
}
|
||||
|
||||
fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
|
||||
if (length <= 2) {
|
||||
return firstFullChar()
|
||||
}
|
||||
|
||||
if (this[0] == ':') {
|
||||
// makes sure an emoji exists
|
||||
val emojiParts = this.split(":", limit = 3)
|
||||
if (emojiParts.size >= 2) {
|
||||
val emojiName = emojiParts[1]
|
||||
val emojiUrl = tags.lists.firstOrNull() { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
|
||||
if (emojiUrl != null) {
|
||||
return ":$emojiName:$emojiUrl"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstFullChar()
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package com.vitorpamplona.amethyst.service
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
@@ -16,15 +16,16 @@ class FileHeader(
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val description: String? = null
|
||||
val description: String? = null,
|
||||
val sensitiveContent: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
|
||||
if (imageData != null) {
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
prepare(imageData, fileUrl, mimeType, description, sensitiveContent, onReady, onError)
|
||||
} else {
|
||||
onError()
|
||||
}
|
||||
@@ -39,13 +40,12 @@ class FileHeader(
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
description: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
try {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(data).toHexKey()
|
||||
val hash = CryptoUtils.sha256(data).toHexKey()
|
||||
val size = data.size
|
||||
|
||||
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
|
||||
@@ -79,7 +79,7 @@ class FileHeader(
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description, sensitiveContent))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
|
||||
@@ -5,19 +5,29 @@ import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
object HttpClient {
|
||||
private var proxy: Proxy? = null
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
|
||||
if (oldValue != newValue) {
|
||||
proxyChangeListeners.forEach {
|
||||
it()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start(account: Account?) {
|
||||
this.proxy = account?.proxy
|
||||
this.internalProxy = account?.proxy
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val seconds = if (internalProxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.proxy(internalProxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
@@ -25,7 +35,7 @@ object HttpClient {
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return proxy
|
||||
return internalProxy
|
||||
}
|
||||
|
||||
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.HandlerThread
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class LocationUtil(context: Context) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 1000L
|
||||
const val MIN_DISTANCE: Float = 0.0f
|
||||
}
|
||||
|
||||
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private var locationListener: LocationListener? = null
|
||||
|
||||
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
|
||||
val providerState = mutableStateOf(false)
|
||||
val isStart: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
private val locHandlerThread = HandlerThread("LocationUtil Thread")
|
||||
|
||||
init {
|
||||
locHandlerThread.start()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(minTimeMs: Long = MIN_TIME, minDistanceM: Float = MIN_DISTANCE) {
|
||||
locationListener().let {
|
||||
locationListener = it
|
||||
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, minTimeMs, minDistanceM, it, locHandlerThread.looper)
|
||||
}
|
||||
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
isStart.value = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
locationListener?.let {
|
||||
locationManager.removeUpdates(it)
|
||||
}
|
||||
isStart.value = false
|
||||
}
|
||||
|
||||
private fun locationListener() = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
locationStateFlow.value = location
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
providerState.value = true
|
||||
}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
providerState.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReverseGeoLocationUtil {
|
||||
suspend fun execute(
|
||||
location: Location,
|
||||
context: Context
|
||||
): String? {
|
||||
return try {
|
||||
Geocoder(context).getFromLocation(location.latitude, location.longitude, 1)?.firstOrNull()?.let { address ->
|
||||
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode).joinToString(", ")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
class NIP30Parser {
|
||||
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
@@ -18,7 +20,7 @@ class NIP30Parser {
|
||||
|
||||
val regularChars = input.split(customEmojiPattern.toRegex())
|
||||
|
||||
var finalList = mutableListOf<String>()
|
||||
val finalList = mutableListOf<String>()
|
||||
var index = 0
|
||||
for (e in regularChars) {
|
||||
finalList.add(e)
|
||||
|
||||
@@ -2,10 +2,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
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
@@ -19,65 +16,63 @@ class Nip05Verifier() {
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
|
||||
}
|
||||
if (parts.size == 1) {
|
||||
return "https://${parts[0]}/.well-known/nostr.json?name=_"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// checkNotInMainThread()
|
||||
suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
|
||||
}
|
||||
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
onError("Could not resolve '$url': ${e.message}")
|
||||
}
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
onError("Could not resolve '$url': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// check fails on tests
|
||||
// checkNotInMainThread()
|
||||
checkNotInMainThread()
|
||||
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchNip05Json(
|
||||
nip05,
|
||||
onSuccess = {
|
||||
checkNotInMainThread()
|
||||
|
||||
// NIP05 usernames are case insensitive, but JSON properties are not
|
||||
// converts the json to lowercase and then tries to access the username via a
|
||||
// lowercase version of the username.
|
||||
@@ -88,7 +83,12 @@ class Nip05Verifier() {
|
||||
null
|
||||
}
|
||||
|
||||
val user = nip05.split("@")[0].lowercase()
|
||||
val parts = nip05.split("@")
|
||||
val user = if (parts.size == 2) {
|
||||
parts[0].lowercase()
|
||||
} else {
|
||||
"_"
|
||||
}
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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?)
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
|
||||
// Rename to the corect nip number when ready.
|
||||
object Nip47 {
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
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
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
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.model.LocalCache
|
||||
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
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
@@ -47,13 +48,24 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountRelayListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.kind, StatusEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 5
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
kinds = listOf(BadgeProfilesEvent.kind, EmojiPackSelectionEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
limit = 10
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -80,6 +92,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountLastPostsListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 400
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
@@ -101,19 +123,56 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
)
|
||||
|
||||
fun createGiftWrapsToMeFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(GiftWrapEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
)
|
||||
)
|
||||
|
||||
val accountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun consume(event: Event, relay: Relay) {
|
||||
if (LocalCache.justVerify(event)) {
|
||||
if (event is GiftWrapEvent) {
|
||||
val privateKey = account.keyPair.privKey
|
||||
if (privateKey != null) {
|
||||
event.cachedGift(privateKey)?.let {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event is SealedGossipEvent) {
|
||||
val privateKey = account.keyPair.privKey
|
||||
if (privateKey != null) {
|
||||
event.cachedGossip(privateKey)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't store sealed gossips to avoid rebroadcasting by mistake.
|
||||
} else {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createAccountRelayListFilter(),
|
||||
createNotificationFilter(),
|
||||
createGiftWrapsToMeFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter()
|
||||
createAccountBookmarkListFilter(),
|
||||
createAccountLastPostsListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
|
||||
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
var account: Account? = null
|
||||
@@ -25,7 +28,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
fun createMessagesByMeToChannelFilter(): TypedFilter? {
|
||||
val myAccount = account ?: return null
|
||||
|
||||
if (channel != null) {
|
||||
if (channel is PublicChatChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
@@ -37,12 +40,24 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
|
||||
authors = listOf(myAccount.userProfile().pubkeyHex),
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel != null) {
|
||||
if (channel is PublicChatChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
@@ -51,6 +66,15 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
|
||||
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
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
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
|
||||
object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
private var withRoom: ChatroomKey? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, user: User) {
|
||||
account = accountIn
|
||||
withUser = user
|
||||
private val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, withRoom: ChatroomKey) {
|
||||
this.account = accountIn
|
||||
this.withRoom = withRoom
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createMessagesToMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
val myPeer = withRoom
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(myPeer.pubkeyHex),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
authors = myPeer.users.map { it },
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -35,7 +39,7 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
}
|
||||
|
||||
fun createMessagesFromMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
val myPeer = withRoom
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
@@ -43,7 +47,8 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
|
||||
tags = mapOf("p" to myPeer.users.map { it }),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -51,9 +56,14 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
val inandoutChannel = requestNewChannel()
|
||||
val inandoutChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
|
||||
inandoutChannel.typedFilters = listOfNotNull(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.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
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
|
||||
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
@@ -44,17 +44,23 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
)
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList(),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
fun createMyChannelsFilter(): TypedFilter {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = followingEvents.toList(),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
@@ -66,15 +72,17 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
|
||||
limit = 25 // Remember to consider spam that is being removed from the UI
|
||||
limit = 50 // Remember to consider spam that is being removed from the UI
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
|
||||
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
|
||||
private var communityToWatch: AddressableNote? = null
|
||||
|
||||
private fun createLoadCommunityFilter(): TypedFilter? {
|
||||
val myCommunityToWatch = communityToWatch ?: return null
|
||||
|
||||
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
authors = community.moderators().map { it.key }.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
|
||||
tags = mapOf(
|
||||
"a" to listOf(myCommunityToWatch.address.toTag())
|
||||
),
|
||||
kinds = listOf(CommunityPostApprovalEvent.kind),
|
||||
limit = 500
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadCommunityChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadCommunity(note: AddressableNote?) {
|
||||
communityToWatch = note
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,23 @@ 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.Event
|
||||
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 com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlin.Error
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
data class Counter(var counter: Int)
|
||||
|
||||
@@ -39,7 +41,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
}
|
||||
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
consume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +59,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(Date().time / 1000, relay.url)
|
||||
subscriptions[channel]?.updateEOSE(TimeUtils.now(), relay.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +72,15 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("Init", "${this.javaClass.simpleName} Subscribe")
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
stop()
|
||||
Client.unsubscribe(clientListener)
|
||||
scope.cancel()
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
@@ -106,18 +111,19 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateFilters() {
|
||||
bundler.invalidate() {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
scope.launch(Dispatchers.IO) {
|
||||
bundler.invalidate() {
|
||||
// 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()
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
@@ -163,6 +169,10 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
open fun consume(event: Event, relay: Relay) {
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
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
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
|
||||
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createLiveStreamFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatsTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatsGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val discoveryFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters = listOfNotNull(
|
||||
createLiveStreamFilter(),
|
||||
createPublicChatFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createLiveStreamTagsFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createCommunitiesTagsFilter(),
|
||||
createCommunitiesGeohashesFilter(),
|
||||
createPublicChatsGeohashesFilter(),
|
||||
createLiveStreamGeohashesFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
|
||||
private var geohashToWatch: String? = null
|
||||
|
||||
fun createLoadHashtagFilter(): TypedFilter? {
|
||||
val hashToLoad = geohashToWatch ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
tags = mapOf(
|
||||
"g" to listOf(
|
||||
hashToLoad
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadGeohashChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadHashtag(tag: String?) {
|
||||
geohashToWatch = tag
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
val globalFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
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.PollNoteEvent
|
||||
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
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
private var hashtagToWatch: String? = null
|
||||
@@ -26,7 +30,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
hashToLoad.capitalize()
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,18 +2,23 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
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
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -49,13 +54,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
|
||||
|
||||
val followKeys = follows.map {
|
||||
it.substring(0, 6)
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 8))
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
@@ -64,11 +69,15 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
TextNoteEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ClassifiedsEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind
|
||||
AudioHeaderEvent.kind,
|
||||
PinListEvent.kind,
|
||||
LiveActivitiesChatMessageEvent.kind,
|
||||
LiveActivitiesEvent.kind
|
||||
),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
@@ -85,7 +94,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
@@ -97,11 +106,63 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowCommunitiesFilter(): TypedFilter? {
|
||||
val communitiesToLoad = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
if (communitiesToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
ClassifiedsEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioHeaderEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind,
|
||||
CommunityPostApprovalEvent.kind
|
||||
),
|
||||
tags = mapOf(
|
||||
"a" to communitiesToLoad.toList()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
followAccountChannel.typedFilters = listOfNotNull(
|
||||
createFollowAccountsFilter(),
|
||||
createFollowCommunitiesFilter(),
|
||||
createFollowTagsFilter(),
|
||||
createFollowGeohashesFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
|
||||
class NostrLnZapPaymentResponseDataSource(
|
||||
private val fromServiceHex: String,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
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
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
private var searchString: String? = null
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
@@ -54,6 +54,29 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, BadgeDefinitionEvent.kind,
|
||||
PeopleListEvent.kind, BookmarkListEvent.kind, AudioHeaderEvent.kind,
|
||||
AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind,
|
||||
ChannelCreateEvent.kind
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
ChannelMetadataEvent.kind,
|
||||
ClassifiedsEvent.kind,
|
||||
CommunityDefinitionEvent.kind,
|
||||
EmojiPackEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
LiveActivitiesEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
NNSEvent.kind
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100
|
||||
)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
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
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
|
||||
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
@@ -30,8 +32,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.map { LocalCache.getOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() }
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() && it is PublicChatChannel }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
@@ -49,13 +51,43 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filterIsInstance<LiveActivitiesChannel>()
|
||||
.filter { it.info == null }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return directEventsToLoad.map {
|
||||
it.address().let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
|
||||
|
||||
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
|
||||
singleChannelChannel.typedFilters = (
|
||||
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
|
||||
@@ -2,17 +2,17 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
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 com.vitorpamplona.quartz.events.*
|
||||
|
||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
private var addressesToWatch = setOf<Note>()
|
||||
|
||||
private fun createTagToAddressFilter(): List<TypedFilter>? {
|
||||
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
|
||||
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
@@ -25,15 +25,19 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, GenericRepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
|
||||
PeopleListEvent.kind, BookmarkListEvent.kind
|
||||
TextNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
CommunityPostApprovalEvent.kind,
|
||||
LiveActivitiesChatMessageEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
since = it.lastReactionsDownloadTime,
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -49,14 +53,26 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex.substring(0, 8))
|
||||
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
authors = listOf(aTag.pubKeyHex),
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex),
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,20 +90,16 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapRequestEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind
|
||||
PollNoteEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
since = it.lastReactionsDownloadTime,
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -122,6 +134,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel { time, relayUrl ->
|
||||
checkNotInMainThread()
|
||||
|
||||
eventsToWatch.forEach {
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
@@ -140,7 +154,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createTagToAddressFilter()
|
||||
val addressReactions = createReactionsToWatchInAddressFilter()
|
||||
|
||||
singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
|
||||
}
|
||||
@@ -159,16 +173,16 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
fun addAddress(aTag: Note) {
|
||||
if (!addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
fun addAddress(addressableNote: Note) {
|
||||
if (!addressesToWatch.contains(addressableNote)) {
|
||||
addressesToWatch = addressesToWatch.plus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAddress(aTag: Note) {
|
||||
if (addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
fun removeAddress(addressableNote: Note) {
|
||||
if (addressesToWatch.contains(addressableNote)) {
|
||||
addressesToWatch = addressesToWatch.minus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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.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 com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
|
||||
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
var usersToWatch = setOf<User>()
|
||||
@@ -26,6 +27,21 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
fun createUserStatusFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(StatusEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
since = it.latestEOSEs
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createUserReportFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
@@ -50,17 +66,17 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val userChannelOnce = requestNewChannel() { time, relayUrl ->
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
val userChannelOnce = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
|
||||
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
|
||||
userChannel.typedFilters = listOfNotNull(createUserReportFilter(), createUserStatusFilter()).flatten().ifEmpty { null }
|
||||
userChannelOnce.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(user: User) {
|
||||
|
||||
@@ -36,8 +36,10 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String?) {
|
||||
eventToWatch = noteId
|
||||
if (eventToWatch != noteId) {
|
||||
eventToWatch = noteId
|
||||
|
||||
invalidateFilters()
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
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
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
|
||||
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
@@ -34,6 +34,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
RepostEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
AudioHeaderEvent.kind,
|
||||
PinListEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
@@ -22,7 +25,8 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200
|
||||
limit = 200,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -41,14 +45,41 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
fun createFollowGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
videoFeedChannel.typedFilters = listOfNotNull(
|
||||
createContextualFilter(),
|
||||
createFollowTagsFilter(),
|
||||
createFollowGeohashesFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Request
|
||||
|
||||
@Immutable
|
||||
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
|
||||
|
||||
object OnlineChecker {
|
||||
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
|
||||
val fiveMinutes = 1000 * 60 * 5
|
||||
|
||||
fun isOnline(url: String?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (url.isNullOrBlank()) return false
|
||||
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
|
||||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
|
||||
return try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val result = HttpClient.getHttpClient().newCall(request).execute().use {
|
||||
it.isSuccessful
|
||||
}
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
|
||||
Log.e("LiveActivities", "Failed to check streaming url $url", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service.connectivitystatus
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
||||
object ConnectivityStatus {
|
||||
private val onMobileData = mutableStateOf(false)
|
||||
val isOnMobileData: MutableState<Boolean> = onMobileData
|
||||
|
||||
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
|
||||
if (onMobileData.value != isOnMobileData) {
|
||||
onMobileData.value = isOnMobileData
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,11 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import com.vitorpamplona.quartz.encoders.Bech32
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.toLnUrl
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
@@ -38,102 +37,88 @@ class LightningAddressResolver() {
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
private suspend fun fetchLightningAddressJson(lnaddress: String, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(response.body.string())
|
||||
} else {
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(response.body.string())
|
||||
} else {
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = {
|
||||
onSuccess(Bech32.encodeBytes("lnurl", it.toByteArray(), Bech32.Encoding.Bech32))
|
||||
onSuccess(it.toByteArray().toLnUrl())
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
|
||||
suspend 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(
|
||||
@@ -172,7 +157,7 @@ class LightningAddressResolver() {
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.let { pr ->
|
||||
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
data class Contact(val pubKeyHex: String, val relayUri: String?)
|
||||
|
||||
class ContactListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
// This function is only used by the user logged in
|
||||
// But it is used all the time.
|
||||
val verifiedFollowKeySet: Set<HexKey> by lazy {
|
||||
tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
|
||||
try {
|
||||
decodePublicKey(it[1]).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 {
|
||||
Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2))
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 3
|
||||
|
||||
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
|
||||
val content = if (relayUse != null) {
|
||||
gson.toJson(relayUse)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = follows.map {
|
||||
if (it.relayUri != null) {
|
||||
listOf("p", it.pubKeyHex, it.relayUri)
|
||||
} else {
|
||||
listOf("p", it.pubKeyHex)
|
||||
}
|
||||
} + 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())
|
||||
}
|
||||
}
|
||||
|
||||
data class ReadWrite(val read: Boolean, val write: Boolean)
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.*
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
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.*
|
||||
|
||||
@Immutable
|
||||
open class Event(
|
||||
val id: HexKey,
|
||||
@SerializedName("pubkey") val pubKey: HexKey,
|
||||
@SerializedName("created_at") val createdAt: Long,
|
||||
val kind: Int,
|
||||
val tags: List<List<String>>,
|
||||
val content: String,
|
||||
val sig: HexKey
|
||||
) : EventInterface {
|
||||
override fun id(): HexKey = id
|
||||
|
||||
override fun pubKey(): HexKey = pubKey
|
||||
|
||||
override fun createdAt(): Long = createdAt
|
||||
|
||||
override fun kind(): Int = kind
|
||||
|
||||
override fun tags(): List<List<String>> = tags
|
||||
|
||||
override fun content(): String = content
|
||||
|
||||
override fun sig(): HexKey = sig
|
||||
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
|
||||
|
||||
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
|
||||
|
||||
override fun isSensitive() = tags.any {
|
||||
(it.size > 0 && it[0].equals("content-warning", true)) ||
|
||||
(it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
|
||||
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
|
||||
}
|
||||
|
||||
override fun zapraiserAmount() = tags.firstOrNull() {
|
||||
(it.size > 1 && it[0].equals("zapraiser", true))
|
||||
}?.get(1)?.toLongOrNull()
|
||||
|
||||
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
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.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||
*/
|
||||
override fun checkSignature() {
|
||||
if (!id.contentEquals(generateId())) {
|
||||
throw Exception(
|
||||
"""|Unexpected ID.
|
||||
| Event: ${toJson()}
|
||||
| Actual ID: $id
|
||||
| Generated: ${generateId()}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
|
||||
throw Exception("""Bad signature!""")
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasValidSignature(): Boolean {
|
||||
return try {
|
||||
id.contentEquals(generateId()) && secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Event", "Fail checking if event $id has a valid signature", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateId(): String {
|
||||
val rawEvent = listOf(0, pubKey, createdAt, kind, tags, content)
|
||||
|
||||
// GSON decided to hardcode these replacements.
|
||||
// They break Nostr's hash check.
|
||||
// These lines revert their code.
|
||||
// https://github.com/google/gson/issues/2295
|
||||
val rawEventJson = gson.toJson(rawEvent)
|
||||
.replace("\\u2028", "\u2028")
|
||||
.replace("\\u2029", "\u2029")
|
||||
|
||||
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray()).toHexKey()
|
||||
}
|
||||
|
||||
private class EventDeserializer : JsonDeserializer<Event> {
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type?,
|
||||
context: JsonDeserializationContext?
|
||||
): Event {
|
||||
val jsonObject = json.asJsonObject
|
||||
return Event(
|
||||
id = jsonObject.get("id").asString,
|
||||
pubKey = jsonObject.get("pubkey").asString,
|
||||
createdAt = jsonObject.get("created_at").asLong,
|
||||
kind = jsonObject.get("kind").asInt,
|
||||
tags = jsonObject.get("tags").asJsonArray.map {
|
||||
it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString }
|
||||
},
|
||||
content = jsonObject.get("content").asString,
|
||||
sig = jsonObject.get("sig").asString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class EventSerializer : JsonSerializer<Event> {
|
||||
override fun serialize(
|
||||
src: Event,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
addProperty("id", src.id)
|
||||
addProperty("pubkey", src.pubKey)
|
||||
addProperty("created_at", src.createdAt)
|
||||
addProperty("kind", src.kind)
|
||||
add(
|
||||
"tags",
|
||||
JsonArray().also { jsonTags ->
|
||||
src.tags.forEach { tag ->
|
||||
jsonTags.add(
|
||||
JsonArray().also { jsonTagElement ->
|
||||
tag.forEach { tagElement ->
|
||||
jsonTagElement.add(tagElement)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
addProperty("content", src.content)
|
||||
addProperty("sig", src.sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type?,
|
||||
context: JsonDeserializationContext?
|
||||
): ByteArray = Hex.decode(json.asString)
|
||||
}
|
||||
|
||||
private class ByteArraySerializer : JsonSerializer<ByteArray> {
|
||||
override fun serialize(
|
||||
src: ByteArray,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
) = JsonPrimitive(src.toHex())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val secp256k1 = Secp256k1.get()
|
||||
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(Event::class.java, EventSerializer())
|
||||
.registerTypeAdapter(Event::class.java, EventDeserializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
|
||||
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
|
||||
.registerTypeAdapter(Request::class.java, RequestDeserializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
|
||||
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
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)
|
||||
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
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)
|
||||
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
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)
|
||||
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
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)
|
||||
RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
else -> this
|
||||
}
|
||||
|
||||
fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: List<List<String>>, content: String): ByteArray {
|
||||
val rawEvent = listOf(
|
||||
0,
|
||||
pubKey,
|
||||
createdAt,
|
||||
kind,
|
||||
tags,
|
||||
content
|
||||
)
|
||||
|
||||
// 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 MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
|
||||
}
|
||||
|
||||
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey).toHexKey()
|
||||
return Event(id.toHexKey(), pubKey, createdAt, kind, tags, content, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
interface AddressableEvent {
|
||||
fun dTag(): String
|
||||
fun address(): ATag
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Immutable
|
||||
interface EventInterface {
|
||||
fun id(): HexKey
|
||||
|
||||
fun pubKey(): HexKey
|
||||
|
||||
fun createdAt(): Long
|
||||
|
||||
fun kind(): Int
|
||||
|
||||
fun tags(): List<List<String>>
|
||||
|
||||
fun content(): String
|
||||
|
||||
fun sig(): HexKey
|
||||
|
||||
fun toJson(): String
|
||||
|
||||
fun checkSignature()
|
||||
|
||||
fun hasValidSignature(): 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?
|
||||
fun isSensitive(): Boolean
|
||||
fun zapraiserAmount(): Long?
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class PeopleListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
companion object {
|
||||
const val kind = 30000
|
||||
|
||||
fun create(
|
||||
name: String = "",
|
||||
|
||||
events: List<String>? = null,
|
||||
users: List<String>? = null,
|
||||
addresses: List<ATag>? = null,
|
||||
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PeopleListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
events?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
users?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ReactionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
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
|
||||
|
||||
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("+", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
|
||||
if (originalNote is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service.model.zaps
|
||||
package com.vitorpamplona.quartz.events.zaps
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
|
||||
import com.vitorpamplona.quartz.events.LnZapEventInterface
|
||||
|
||||
object UserZaps {
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
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, EVENT, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
@Immutable
|
||||
data class Return(
|
||||
val type: Type,
|
||||
val hex: String,
|
||||
val relay: String? = null,
|
||||
val author: String? = null,
|
||||
val kind: Int? = null,
|
||||
val additionalChars: String = ""
|
||||
)
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
if (uri == null) return null
|
||||
|
||||
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) {
|
||||
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())
|
||||
}
|
||||
|
||||
private fun note(bytes: ByteArray): Return {
|
||||
return Return(Type.NOTE, bytes.toHexKey())
|
||||
}
|
||||
|
||||
private fun nprofile(bytes: ByteArray): Return? {
|
||||
val tlv = Tlv.parse(bytes)
|
||||
|
||||
val hex = tlv.get(Tlv.Type.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
|
||||
val relay = tlv.get(Tlv.Type.RELAY.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
return Return(Type.USER, hex, relay)
|
||||
}
|
||||
|
||||
private fun nevent(bytes: ByteArray): Return? {
|
||||
val tlv = Tlv.parse(bytes)
|
||||
|
||||
val hex = tlv.get(Tlv.Type.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
|
||||
val relay = tlv.get(Tlv.Type.RELAY.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
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) }
|
||||
|
||||
return Return(Type.EVENT, hex, relay, author, kind)
|
||||
}
|
||||
|
||||
private fun nrelay(bytes: ByteArray): Return? {
|
||||
val relayUrl = Tlv.parse(bytes)
|
||||
.get(Tlv.Type.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
|
||||
return Return(Type.RELAY, relayUrl)
|
||||
}
|
||||
|
||||
private fun naddr(bytes: ByteArray): Return? {
|
||||
val tlv = Tlv.parse(bytes)
|
||||
|
||||
val d = tlv.get(Tlv.Type.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
|
||||
val relay = tlv.get(Tlv.Type.RELAY.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
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) }
|
||||
|
||||
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?.hexToByteArray()
|
||||
val idHex = idHex.hexToByteArray()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
object Tlv {
|
||||
enum class Type(val id: Byte) {
|
||||
SPECIAL(0),
|
||||
RELAY(1),
|
||||
AUTHOR(2),
|
||||
KIND(3);
|
||||
}
|
||||
|
||||
fun toInt32(bytes: ByteArray): Int {
|
||||
require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
|
||||
return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
|
||||
}
|
||||
|
||||
fun parse(data: ByteArray): Map<Byte, List<ByteArray>> {
|
||||
val result = mutableMapOf<Byte, MutableList<ByteArray>>()
|
||||
var rest = data
|
||||
while (rest.isNotEmpty()) {
|
||||
val t = rest[0]
|
||||
val l = rest[1]
|
||||
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
|
||||
rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
|
||||
if (v.size < l) continue
|
||||
|
||||
if (!result.containsKey(t)) {
|
||||
result[t] = mutableListOf()
|
||||
}
|
||||
result[t]?.add(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -5,30 +5,93 @@ import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
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.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
fun consume(event: Event) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
if (LocalCache.notes[event.id] == null) {
|
||||
// adds to database
|
||||
LocalCache.verifyAndConsume(event, null)
|
||||
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
fun consume(event: Event) {
|
||||
if (LocalCache.notes[event.id] == null) {
|
||||
if (LocalCache.justVerify(event)) {
|
||||
LocalCache.justConsume(event, null)
|
||||
|
||||
val manager = notificationManager()
|
||||
if (manager.areNotificationsEnabled()) {
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
is GiftWrapEvent -> unwrapAndNotify(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unwrapAndConsume(event: Event, account: Account): Event? {
|
||||
if (!LocalCache.justVerify(event)) return null
|
||||
|
||||
return when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
val key = account.keyPair.privKey ?: return null
|
||||
event.cachedGift(key)?.let {
|
||||
unwrapAndConsume(it, account)
|
||||
}
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
val key = account.keyPair.privKey ?: return null
|
||||
event.cachedGossip(key)?.let {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
it
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LocalCache.justConsume(event, null)
|
||||
event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unwrapAndNotify(giftWrap: GiftWrapEvent) {
|
||||
val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == giftWrap.recipientPubKey()) {
|
||||
val chatEvent = unwrapAndConsume(giftWrap, account = acc)
|
||||
|
||||
if (chatEvent is ChatMessageEvent && acc.keyPair.privKey != null) {
|
||||
val chatNote = LocalCache.notes[chatEvent.id] ?: return
|
||||
val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val isKnownRoom = (
|
||||
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(chatRoom)
|
||||
) && !acc.isAllHidden(chatRoom.users)
|
||||
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri = chatNote.toNEvent()
|
||||
notificationManager().sendDMNotification(chatEvent.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,19 +106,21 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val messagingWith = acc.userProfile().privateChatrooms.keys.filter {
|
||||
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
||||
(
|
||||
it.pubkeyHex in followingKeySet || acc.userProfile()
|
||||
.hasSentMessagesTo(it)
|
||||
) && !acc.isHidden(it)
|
||||
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(it)
|
||||
) && !acc.isAllHidden(it.users)
|
||||
}.toSet()
|
||||
|
||||
if (note.author in messagingWith) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
note.author?.let {
|
||||
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +168,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationManager(): NotificationManager {
|
||||
fun notificationManager(): NotificationManager {
|
||||
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
object NotificationUtils {
|
||||
private var dmChannel: NotificationChannel? = null
|
||||
private var zapChannel: NotificationChannel? = null
|
||||
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
||||
|
||||
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
if (dmChannel != null) return dmChannel!!
|
||||
|
||||
dmChannel = NotificationChannel(
|
||||
@@ -39,7 +41,7 @@ object NotificationUtils {
|
||||
return dmChannel!!
|
||||
}
|
||||
|
||||
private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
|
||||
fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
|
||||
if (zapChannel != null) return zapChannel!!
|
||||
|
||||
zapChannel = NotificationChannel(
|
||||
@@ -70,7 +72,7 @@ object NotificationUtils {
|
||||
val zapChannel = getOrCreateZapChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
|
||||
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, ZAP_GROUP_KEY, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendDMNotification(
|
||||
@@ -84,7 +86,7 @@ object NotificationUtils {
|
||||
val dmChannel = getOrCreateDMChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
|
||||
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, DM_GROUP_KEY, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendNotification(
|
||||
@@ -94,6 +96,7 @@ object NotificationUtils {
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
notificationGroupKey: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
if (pictureUrl != null) {
|
||||
@@ -110,6 +113,7 @@ object NotificationUtils {
|
||||
picture = imageResult.drawable as? BitmapDrawable,
|
||||
uri = uri,
|
||||
channelId,
|
||||
notificationGroupKey,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
} else {
|
||||
@@ -120,6 +124,7 @@ object NotificationUtils {
|
||||
picture = null,
|
||||
uri = uri,
|
||||
channelId,
|
||||
notificationGroupKey,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
}
|
||||
@@ -132,6 +137,7 @@ object NotificationUtils {
|
||||
picture: BitmapDrawable?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
notificationGroupKey: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
@@ -164,7 +170,8 @@ object NotificationUtils {
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.setGroup(messageTitle)
|
||||
// .setGroup(messageTitle)
|
||||
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
@@ -178,7 +185,8 @@ object NotificationUtils {
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(messageBody)
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.setGroup(messageTitle)
|
||||
// .setGroup(messageTitle)
|
||||
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPublicVersion(builderPublic.build())
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
|
||||
@@ -5,11 +5,9 @@ import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -56,18 +54,17 @@ class RegisterAccounts(
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
client.newCall(request).execute()
|
||||
val isSucess = client.newCall(request).execute().use {
|
||||
it.isSuccessful
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e("FirebaseMsgService", "Unable to register with push server", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun go(notificationToken: String) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
}
|
||||
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
|
||||
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||
|
||||
fun fetchUrlPreview(timeOut: Int = 30000) {
|
||||
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
scope.launch(exceptionHandler) {
|
||||
suspend fun fetchUrlPreview(timeOut: Int = 30000) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
fetch(timeOut)
|
||||
} catch (t: Throwable) {
|
||||
callback?.onFailed(t)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetch(timeOut: Int = 30000) {
|
||||
checkNotInMainThread()
|
||||
lateinit var urlInfoItem: UrlInfoItem
|
||||
if (checkIsImageUrl()) {
|
||||
urlInfoItem = UrlInfoItem(url = url, image = url)
|
||||
@@ -43,7 +39,6 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
scope.cancel()
|
||||
callback = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
interface IUrlPreviewCallback {
|
||||
fun onComplete(urlInfo: UrlInfoItem)
|
||||
fun onFailed(throwable: Throwable)
|
||||
suspend fun onComplete(urlInfo: UrlInfoItem)
|
||||
suspend fun onFailed(throwable: Throwable)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.previews
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
@@ -61,10 +62,12 @@ private const val CONTENT = "content"
|
||||
|
||||
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
|
||||
withContext(Dispatchers.IO) {
|
||||
return@withContext Jsoup.connect(url)
|
||||
.proxy(HttpClient.getProxy())
|
||||
.timeout(timeOut)
|
||||
.get()
|
||||
val request: Request = Request.Builder().url(url).get().build()
|
||||
val html = HttpClient.getHttpClient().newCall(request).execute().use {
|
||||
it.body.string()
|
||||
}
|
||||
|
||||
Jsoup.parse(html)
|
||||
}
|
||||
|
||||
suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -17,16 +17,6 @@ import java.util.UUID
|
||||
* Events are stored with their respective persona.
|
||||
*/
|
||||
object Client : RelayPool.Listener {
|
||||
/**
|
||||
* Lenient mode:
|
||||
*
|
||||
* true: For maximum compatibility. If you want to play ball with sloppy counterparts, use
|
||||
* this.
|
||||
* false: For developers who want to make protocol compliant counterparts. If your software
|
||||
* produces events that fail to deserialize in strict mode, you should probably fix
|
||||
* something.
|
||||
**/
|
||||
var lenient: Boolean = false
|
||||
private var listeners = setOf<Listener>()
|
||||
private var relays = Constants.convertDefaultRelays()
|
||||
private var subscriptions = mapOf<String, List<TypedFilter>>()
|
||||
@@ -73,10 +63,18 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
|
||||
fun send(
|
||||
signedEvent: EventInterface,
|
||||
relay: String? = null,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
onDone: (() -> Unit)? = null
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (relay == null) {
|
||||
if (relayList != null) {
|
||||
RelayPool.sendToSelectedRelays(relayList, signedEvent)
|
||||
} else if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
|
||||
@@ -104,7 +102,7 @@ object Client : RelayPool.Listener {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
relay.requestAndWatch {
|
||||
relay.connectAndRun {
|
||||
allSubscriptions().forEach {
|
||||
relay.sendFilter(requestId = it)
|
||||
}
|
||||
@@ -170,6 +168,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
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.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
|
||||
class JsonFilter(
|
||||
val ids: List<String>? = null,
|
||||
@@ -15,51 +12,64 @@ class JsonFilter(
|
||||
val limit: Int? = null,
|
||||
val search: String? = null
|
||||
) {
|
||||
|
||||
fun toJson(forRelay: String? = null): String {
|
||||
val jsonObject = JsonObject()
|
||||
ids?.run {
|
||||
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
|
||||
}
|
||||
authors?.run {
|
||||
jsonObject.add("authors", JsonArray().apply { authors.forEach { add(it) } })
|
||||
}
|
||||
kinds?.run {
|
||||
jsonObject.add("kinds", JsonArray().apply { kinds.forEach { add(it) } })
|
||||
}
|
||||
tags?.run {
|
||||
entries.forEach { kv ->
|
||||
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
|
||||
val factory = Event.mapper.nodeFactory
|
||||
val filter = factory.objectNode().apply {
|
||||
ids?.run {
|
||||
put(
|
||||
"ids",
|
||||
factory.arrayNode(ids.size).apply {
|
||||
ids.forEach { add(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
since?.run {
|
||||
if (!isEmpty()) {
|
||||
if (forRelay != null) {
|
||||
val relaySince = get(forRelay)
|
||||
if (relaySince != null) {
|
||||
jsonObject.addProperty("since", relaySince.time)
|
||||
authors?.run {
|
||||
put(
|
||||
"authors",
|
||||
factory.arrayNode(authors.size).apply {
|
||||
authors.forEach { add(it) }
|
||||
}
|
||||
} else {
|
||||
val jsonObjectSince = JsonObject()
|
||||
entries.forEach { sincePairs ->
|
||||
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
|
||||
)
|
||||
}
|
||||
kinds?.run {
|
||||
put(
|
||||
"kinds",
|
||||
factory.arrayNode(kinds.size).apply {
|
||||
kinds.forEach { add(it) }
|
||||
}
|
||||
jsonObject.add("since", jsonObjectSince)
|
||||
)
|
||||
}
|
||||
tags?.run {
|
||||
entries.forEach { kv ->
|
||||
put(
|
||||
"#${kv.key}",
|
||||
factory.arrayNode(kv.value.size).apply {
|
||||
kv.value.forEach { add(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
since?.run {
|
||||
if (!isEmpty()) {
|
||||
if (forRelay != null) {
|
||||
val relaySince = get(forRelay)
|
||||
if (relaySince != null) {
|
||||
put("since", relaySince.time)
|
||||
}
|
||||
} else {
|
||||
val jsonObjectSince = factory.objectNode()
|
||||
entries.forEach { sincePairs ->
|
||||
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
|
||||
}
|
||||
put("since", jsonObjectSince)
|
||||
}
|
||||
}
|
||||
}
|
||||
until?.run { put("until", until) }
|
||||
limit?.run { put("limit", limit) }
|
||||
search?.run { put("search", search) }
|
||||
}
|
||||
until?.run {
|
||||
jsonObject.addProperty("until", until)
|
||||
}
|
||||
limit?.run {
|
||||
jsonObject.addProperty("limit", limit)
|
||||
}
|
||||
search?.run {
|
||||
jsonObject.addProperty("search", search)
|
||||
}
|
||||
return gson.toJson(jsonObject)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder().create()
|
||||
return Event.mapper.writeValueAsString(filter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.events.bytesUsedInMemory
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -14,7 +15,7 @@ import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
enum class FeedType {
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
|
||||
@@ -29,14 +30,16 @@ class Relay(
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
|
||||
proxy: Proxy?
|
||||
) {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
companion object {
|
||||
// waits 3 minutes to reconnect once things fail
|
||||
const val RECONNECTING_IN_SECONDS = 60 * 3
|
||||
}
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.readTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
|
||||
.connectTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
|
||||
.writeTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
@@ -50,9 +53,9 @@ class Relay(
|
||||
|
||||
var spamCounter = 0
|
||||
var errorCounter = 0
|
||||
var ping: Long? = null
|
||||
var pingInMs: Long? = null
|
||||
|
||||
var closingTime = 0L
|
||||
var closingTimeInSeconds = 0L
|
||||
|
||||
var afterEOSE = false
|
||||
|
||||
@@ -68,10 +71,8 @@ class Relay(
|
||||
return socket != null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch() {
|
||||
checkNotInMainThread()
|
||||
requestAndWatch {
|
||||
fun connect() {
|
||||
connectAndRun {
|
||||
checkNotInMainThread()
|
||||
|
||||
// Sends everything.
|
||||
@@ -81,9 +82,16 @@ class Relay(
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch(onConnected: (Relay) -> Unit) {
|
||||
private var connectingBlock = AtomicBoolean()
|
||||
|
||||
fun connectAndRun(onConnected: (Relay) -> Unit) {
|
||||
// If there is a connection, don't wait.
|
||||
if (connectingBlock.getAndSet(true)) {
|
||||
return
|
||||
}
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
if (socket != null) return
|
||||
|
||||
try {
|
||||
@@ -91,132 +99,143 @@ class Relay(
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url.trim())
|
||||
.build()
|
||||
val listener = object : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
checkNotInMainThread()
|
||||
socket = httpClient.newWebSocket(request, RelayListener(onConnected))
|
||||
} catch (e: Exception) {
|
||||
errorCounter++
|
||||
markConnectionAsClosed()
|
||||
Log.e("Relay", "Relay Invalid $url")
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
connectingBlock.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
afterEOSE = false
|
||||
isReady = true
|
||||
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
|
||||
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
|
||||
onConnected(this@Relay)
|
||||
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
checkNotInMainThread()
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
|
||||
markConnectionAsReady(response.receivedResponseAtMillis - response.sentRequestAtMillis)
|
||||
|
||||
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
|
||||
onConnected(this@Relay)
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
eventDownloadCounterInBytes += text.bytesUsedInMemory()
|
||||
|
||||
try {
|
||||
processNewRelayMessage(text)
|
||||
} catch (t: Throwable) {
|
||||
t.printStackTrace()
|
||||
text.chunked(2000) { chunked ->
|
||||
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
checkNotInMainThread()
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
eventDownloadCounterInBytes += text.bytesUsedInMemory()
|
||||
listeners.forEach {
|
||||
it.onRelayStateChange(
|
||||
this@Relay,
|
||||
Type.DISCONNECTING,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
|
||||
val type = msg[0].asString
|
||||
val channel = msg[1].asString
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
markConnectionAsClosed()
|
||||
|
||||
// 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")
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
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(
|
||||
this@Relay,
|
||||
channel,
|
||||
Error("Unknown type $type on channel $channel. Msg was $text")
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
t.printStackTrace()
|
||||
text.chunked(2000) { chunked ->
|
||||
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
checkNotInMainThread()
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
checkNotInMainThread()
|
||||
|
||||
listeners.forEach {
|
||||
it.onRelayStateChange(
|
||||
this@Relay,
|
||||
Type.DISCONNECTING,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
errorCounter++
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
checkNotInMainThread()
|
||||
socket?.close(1000, "Normal close")
|
||||
// Failures disconnect the relay.
|
||||
markConnectionAsClosed()
|
||||
|
||||
socket = null
|
||||
isReady = false
|
||||
afterEOSE = false
|
||||
closingTime = Date().time / 1000
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
|
||||
}
|
||||
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
|
||||
t.printStackTrace()
|
||||
listeners.forEach {
|
||||
it.onError(this@Relay, "", Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
checkNotInMainThread()
|
||||
fun markConnectionAsReady(pingInMs: Long) {
|
||||
this.afterEOSE = false
|
||||
this.isReady = true
|
||||
this.pingInMs = pingInMs
|
||||
}
|
||||
|
||||
errorCounter++
|
||||
fun markConnectionAsClosed() {
|
||||
this.socket = null
|
||||
this.isReady = false
|
||||
this.afterEOSE = false
|
||||
this.closingTimeInSeconds = TimeUtils.now()
|
||||
}
|
||||
|
||||
socket?.close(1000, "Normal close")
|
||||
// Failures disconnect the relay.
|
||||
socket = null
|
||||
isReady = false
|
||||
afterEOSE = false
|
||||
closingTime = Date().time / 1000
|
||||
fun processNewRelayMessage(newMessage: String) {
|
||||
val msgArray = Event.mapper.readTree(newMessage)
|
||||
val type = msgArray.get(0).asText()
|
||||
val channel = msgArray.get(1).asText()
|
||||
|
||||
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
|
||||
t.printStackTrace()
|
||||
listeners.forEach {
|
||||
it.onError(this@Relay, "", Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t))
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
val event = Event.fromJson(msgArray.get(2))
|
||||
|
||||
// Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
listeners.forEach {
|
||||
it.onEvent(this@Relay, channel, event)
|
||||
if (afterEOSE) {
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket = httpClient.newWebSocket(request, listener)
|
||||
} catch (e: Exception) {
|
||||
errorCounter++
|
||||
isReady = false
|
||||
afterEOSE = false
|
||||
closingTime = Date().time / 1000
|
||||
Log.e("Relay", "Relay Invalid $url")
|
||||
e.printStackTrace()
|
||||
"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")
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
Log.w("Relay", "Relay on OK $url, ${msgArray[1].asText()}, ${msgArray[2].asBoolean()}, ${msgArray[3].asText()}")
|
||||
it.onSendResponse(this@Relay, msgArray[1].asText(), msgArray[2].asBoolean(), msgArray[3].asText())
|
||||
}
|
||||
"AUTH" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
|
||||
it.onAuth(this@Relay, msgArray[1].asText())
|
||||
}
|
||||
else -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay something else $url, $channel")
|
||||
it.onError(
|
||||
this@Relay,
|
||||
channel,
|
||||
Error("Unknown type $type on channel $channel. Msg was $newMessage")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
// httpClient.dispatcher.executorService.shutdown()
|
||||
closingTime = Date().time / 1000
|
||||
closingTimeInSeconds = TimeUtils.now()
|
||||
socket?.close(1000, "Normal close")
|
||||
socket = null
|
||||
isReady = false
|
||||
@@ -241,9 +260,9 @@ class Relay(
|
||||
}
|
||||
} else {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (Date().time / 1000 > closingTime + 60) {
|
||||
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
|
||||
// sends all filters after connection is successful.
|
||||
requestAndWatch()
|
||||
connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,9 +273,9 @@ class Relay(
|
||||
|
||||
if (socket == null) {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (Date().time / 1000 > closingTime + 60) {
|
||||
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
|
||||
// println("sendfilter Only if Disconnected ${url} ")
|
||||
requestAndWatch()
|
||||
connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,16 +284,36 @@ class Relay(
|
||||
checkNotInMainThread()
|
||||
|
||||
if (signedEvent is RelayAuthEvent) {
|
||||
// specific protocol for this event.
|
||||
val event = """["AUTH",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
} else {
|
||||
if (write) {
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
} else {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
|
||||
// sends all filters after connection is successful.
|
||||
connectAndRun {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (write) {
|
||||
if (signedEvent !is RelayAuthEvent) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,7 +363,3 @@ class Relay(
|
||||
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.bytesUsedInMemory(): Int {
|
||||
return (8 * ((((this.length) * 2) + 45) / 8))
|
||||
}
|
||||
|
||||
@@ -2,20 +2,13 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
|
||||
/**
|
||||
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
|
||||
*/
|
||||
object RelayPool : Relay.Listener {
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
|
||||
private var relays = listOf<Relay>()
|
||||
private var listeners = setOf<Listener>()
|
||||
|
||||
@@ -24,7 +17,7 @@ object RelayPool : Relay.Listener {
|
||||
}
|
||||
|
||||
fun connectedRelays(): Int {
|
||||
return relays.filter { it.isConnected() }.size
|
||||
return relays.count { it.isConnected() }
|
||||
}
|
||||
|
||||
fun getRelay(url: String): Relay? {
|
||||
@@ -51,7 +44,7 @@ object RelayPool : Relay.Listener {
|
||||
fun requestAndWatch() {
|
||||
checkNotInMainThread()
|
||||
|
||||
relays.forEach { it.requestAndWatch() }
|
||||
relays.forEach { it.connect() }
|
||||
}
|
||||
|
||||
fun sendFilter(subscriptionId: String) {
|
||||
@@ -62,6 +55,12 @@ object RelayPool : Relay.Listener {
|
||||
relays.forEach { it.sendFilterOnlyIfDisconnected() }
|
||||
}
|
||||
|
||||
fun sendToSelectedRelays(list: List<Relay>, signedEvent: EventInterface) {
|
||||
list.forEach { relay ->
|
||||
relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) }
|
||||
}
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
relays.forEach { it.send(signedEvent) }
|
||||
}
|
||||
@@ -130,9 +129,7 @@ object RelayPool : Relay.Listener {
|
||||
val live: RelayPoolLiveData = RelayPoolLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
scope.launch {
|
||||
live.refresh()
|
||||
}
|
||||
live.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import java.util.UUID
|
||||
|
||||
data class Subscription(
|
||||
@@ -16,15 +15,24 @@ data class Subscription(
|
||||
}
|
||||
|
||||
fun toJson(): String {
|
||||
return GsonBuilder().create().toJson(toJsonObject())
|
||||
return Event.mapper.writeValueAsString(toJsonObject())
|
||||
}
|
||||
|
||||
fun toJsonObject(): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
jsonObject.addProperty("id", id)
|
||||
typedFilters?.run {
|
||||
jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } })
|
||||
fun toJsonObject(): JsonNode {
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
return factory.objectNode().apply {
|
||||
put("id", id)
|
||||
typedFilters?.also { filters ->
|
||||
put(
|
||||
"typedFilters",
|
||||
factory.arrayNode(filters.size).apply {
|
||||
filters.forEach { filter ->
|
||||
add(filter.toJsonObject())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
|
||||
class TypedFilter(
|
||||
val types: Set<FeedType>,
|
||||
@@ -10,54 +10,74 @@ class TypedFilter(
|
||||
) {
|
||||
|
||||
fun toJson(): String {
|
||||
return GsonBuilder().create().toJson(toJsonObject())
|
||||
return Event.mapper.writeValueAsString(toJsonObject())
|
||||
}
|
||||
|
||||
fun toJsonObject(): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
jsonObject.add("types", typesToJson(types))
|
||||
jsonObject.add("filter", filterToJson(filter))
|
||||
return jsonObject
|
||||
fun toJsonObject(): JsonNode {
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
return factory.objectNode().apply {
|
||||
put("types", typesToJson(types))
|
||||
put("filter", filterToJson(filter))
|
||||
}
|
||||
}
|
||||
|
||||
fun typesToJson(types: Set<FeedType>): JsonArray {
|
||||
return JsonArray().apply { types.forEach { add(it.name.lowercase()) } }
|
||||
fun typesToJson(types: Set<FeedType>): ArrayNode {
|
||||
val factory = Event.mapper.nodeFactory
|
||||
return factory.arrayNode(types.size).apply {
|
||||
types.forEach { add(it.name.lowercase()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun filterToJson(filter: JsonFilter): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
filter.ids?.run {
|
||||
jsonObject.add("ids", JsonArray().apply { filter.ids.forEach { add(it) } })
|
||||
}
|
||||
filter.authors?.run {
|
||||
jsonObject.add("authors", JsonArray().apply { filter.authors.forEach { add(it) } })
|
||||
}
|
||||
filter.kinds?.run {
|
||||
jsonObject.add("kinds", JsonArray().apply { filter.kinds.forEach { add(it) } })
|
||||
}
|
||||
filter.tags?.run {
|
||||
entries.forEach { kv ->
|
||||
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
|
||||
fun filterToJson(filter: JsonFilter): JsonNode {
|
||||
val factory = Event.mapper.nodeFactory
|
||||
return factory.objectNode().apply {
|
||||
filter.ids?.run {
|
||||
put(
|
||||
"ids",
|
||||
factory.arrayNode(filter.ids.size).apply {
|
||||
filter.ids.forEach { add(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
/*
|
||||
Does not include since in the json comparison
|
||||
filter.since?.run {
|
||||
val jsonObjectSince = JsonObject()
|
||||
entries.forEach { sincePairs ->
|
||||
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
|
||||
filter.authors?.run {
|
||||
put(
|
||||
"authors",
|
||||
factory.arrayNode(filter.authors.size).apply {
|
||||
filter.authors.forEach { add(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
jsonObject.add("since", jsonObjectSince)
|
||||
}*/
|
||||
filter.until?.run {
|
||||
jsonObject.addProperty("until", filter.until)
|
||||
filter.kinds?.run {
|
||||
put(
|
||||
"kinds",
|
||||
factory.arrayNode(filter.kinds.size).apply {
|
||||
filter.kinds.forEach { add(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
filter.tags?.run {
|
||||
entries.forEach { kv ->
|
||||
put(
|
||||
"#${kv.key}",
|
||||
factory.arrayNode(kv.value.size).apply {
|
||||
kv.value.forEach { add(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
/*
|
||||
Does not include since in the json comparison
|
||||
filter.since?.run {
|
||||
val jsonObjectSince = JsonObject()
|
||||
entries.forEach { sincePairs ->
|
||||
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
|
||||
}
|
||||
jsonObject.add("since", jsonObjectSince)
|
||||
}*/
|
||||
filter.until?.run { put("until", filter.until) }
|
||||
filter.limit?.run { put("limit", filter.limit) }
|
||||
filter.search?.run { put("search", filter.search) }
|
||||
}
|
||||
filter.limit?.run {
|
||||
jsonObject.addProperty("limit", filter.limit)
|
||||
}
|
||||
filter.search?.run {
|
||||
jsonObject.addProperty("search", filter.search)
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
package com.vitorpamplona.amethyst.service.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -33,6 +34,7 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
|
||||
private var customActionForDestroy: (() -> Unit)? = null
|
||||
|
||||
init {
|
||||
Log.d("Init", "Init TTS")
|
||||
initTTS()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,31 +6,40 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
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.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
|
||||
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.debugState
|
||||
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.screen.ThemeViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -38,25 +47,30 @@ import kotlinx.coroutines.launch
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val uri = intent?.data?.toString()
|
||||
|
||||
val startingPage = uriToRoute(uri)
|
||||
|
||||
LocalPreferences.migrateSingleUserPrefs()
|
||||
val language = LocalPreferences.getPreferredLanguage()
|
||||
if (language.isNotBlank()) {
|
||||
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
|
||||
AppCompatDelegate.setApplicationLocales(appLocale)
|
||||
}
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
val themeViewModel: ThemeViewModel = viewModel()
|
||||
|
||||
themeViewModel.onChange(LocalPreferences.getTheme())
|
||||
AmethystTheme(themeViewModel) {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(this@MainActivity)
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, startingPage)
|
||||
AccountScreen(accountStateViewModel, themeViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,13 +83,12 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
connectivityManager.requestNetwork(networkRequest, networkCallback)
|
||||
|
||||
Client.lenient = true
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// starts muted every time
|
||||
DefaultMutedSetting.value = true
|
||||
|
||||
@@ -84,19 +97,32 @@ class MainActivity : FragmentActivity() {
|
||||
ServiceManager.start(this@MainActivity)
|
||||
}
|
||||
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
ServiceManager.pause()
|
||||
// if (BuildConfig.DEBUG) {
|
||||
debugState(this)
|
||||
// }
|
||||
|
||||
ServiceManager.pause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex?.release()
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Release memory when the UI becomes hidden or when system resources become low.
|
||||
* @param level the memory-related event that was raised.
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
@@ -105,6 +131,7 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
// network is available for use
|
||||
override fun onAvailable(network: Network) {
|
||||
@@ -123,6 +150,17 @@ class MainActivity : FragmentActivity() {
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val hasMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
|
||||
ConnectivityStatus.updateConnectivityStatus(
|
||||
hasMobileData,
|
||||
hasWifi
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// lost network connection
|
||||
@@ -160,7 +198,9 @@ fun uriToRoute(uri: String?): String? {
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> {
|
||||
if (nip19.kind == PrivateDmEvent.kind) {
|
||||
"Room/${nip19.author}"
|
||||
nip19.author?.let {
|
||||
"RoomByAuthor/$it"
|
||||
}
|
||||
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
|
||||
"Channel/${nip19.hex}"
|
||||
} else {
|
||||
@@ -168,7 +208,14 @@ fun uriToRoute(uri: String?): String? {
|
||||
}
|
||||
}
|
||||
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
Nip19.Type.ADDRESS ->
|
||||
if (nip19.kind == CommunityDefinitionEvent.kind) {
|
||||
"Community/${nip19.hex}"
|
||||
} else if (nip19.kind == LiveActivitiesEvent.kind) {
|
||||
"Channel/${nip19.hex}"
|
||||
} else {
|
||||
"Event/${nip19.hex}"
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} ?: try {
|
||||
|
||||
@@ -7,19 +7,22 @@ import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toFile
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okio.BufferedSink
|
||||
import okio.IOException
|
||||
import okio.source
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
|
||||
object ImageUploader {
|
||||
lateinit var account: Account
|
||||
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
@@ -33,12 +36,11 @@ object ImageUploader {
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length = size
|
||||
?: contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: kotlin.runCatching {
|
||||
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: kotlin.runCatching {
|
||||
uri.toFile().length()
|
||||
}.getOrNull() ?: 0
|
||||
|
||||
@@ -58,6 +60,9 @@ object ImageUploader {
|
||||
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
|
||||
NostrFilesDevServer()
|
||||
}
|
||||
ServersAvailable.NOSTRCHECK_ME, ServersAvailable.NOSTRCHECK_ME_NIP_94 -> {
|
||||
NostrCheckMeServer()
|
||||
}
|
||||
else -> {
|
||||
NostrBuildServer()
|
||||
}
|
||||
@@ -99,7 +104,7 @@ object ImageUploader {
|
||||
)
|
||||
.build()
|
||||
|
||||
server.clientID()?.let {
|
||||
server.clientID(requestBody.toString())?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
@@ -133,6 +138,13 @@ object ImageUploader {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun NIP98Header(url: String, method: String, body: String): String {
|
||||
val noteJson = account.createHTTPAuthorization(url, method, body)?.toJson() ?: ""
|
||||
|
||||
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
|
||||
return "Nostr " + encodedNIP98Event
|
||||
}
|
||||
}
|
||||
|
||||
abstract class FileServer {
|
||||
@@ -140,7 +152,7 @@ abstract class FileServer {
|
||||
abstract fun parseUrlFromSuccess(body: String): String?
|
||||
abstract fun inputParameterName(contentType: String?): String
|
||||
|
||||
open fun clientID(): String? = null
|
||||
open fun clientID(info: String): String? = null
|
||||
}
|
||||
|
||||
class NostrImgServer : FileServer() {
|
||||
@@ -156,7 +168,7 @@ class NostrImgServer : FileServer() {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
override fun clientID(info: String) = null
|
||||
}
|
||||
|
||||
class ImgurServer : FileServer() {
|
||||
@@ -175,21 +187,24 @@ class ImgurServer : FileServer() {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = "Client-ID e6aea87296f3f96"
|
||||
override fun clientID(info: String) = "Client-ID e6aea87296f3f96"
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val url = jacksonObjectMapper().readTree(body) // return url.toString()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val data = tree?.get("data")
|
||||
val data0 = data?.get(0)
|
||||
val url = data0?.get("url")
|
||||
return url.toString().replace("\"", "")
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "fileToUpload"
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
override fun clientID(info: String) = ImageUploader.NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info)
|
||||
}
|
||||
|
||||
class NostrFilesDevServer : FileServer() {
|
||||
@@ -203,5 +218,43 @@ class NostrFilesDevServer : FileServer() {
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
override fun clientID(info: String) = null
|
||||
}
|
||||
|
||||
class NostrCheckMeServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("url")?.asText()
|
||||
var id = tree?.get("id")?.asText()
|
||||
var isCompleted = false
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
var requrl = "https://nostrcheck.me/api/v1/media?id=" + id // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(requrl)
|
||||
.addHeader("Authorization", ImageUploader.NIP98Header(requrl, "GET", ""))
|
||||
.get()
|
||||
.build()
|
||||
|
||||
while (!isCompleted) {
|
||||
client.newCall(request).execute().use {
|
||||
val tree = jacksonObjectMapper().readTree(it.body.string())
|
||||
isCompleted = tree?.get("status")?.asText() == "completed"
|
||||
try {
|
||||
Thread.sleep(500)
|
||||
} catch (e: InterruptedException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "mediafile"
|
||||
}
|
||||
|
||||
override fun clientID(body: String) = ImageUploader.NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -33,6 +32,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -44,7 +44,6 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
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.style.TextOverflow
|
||||
@@ -60,11 +59,16 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.note.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -116,7 +120,7 @@ fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () ->
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
searchBarViewModel.clear()
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
onClose()
|
||||
@@ -252,12 +256,7 @@ private fun SearchEditTextForJoin(
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_search),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
SearchIcon(modifier = Size20Modifier, Color.Unspecified)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f, true)
|
||||
@@ -303,6 +302,7 @@ private fun RenderSearchResults(
|
||||
if (searchBarViewModel.isSearching) {
|
||||
val users by searchBarViewModel.searchResultsUsers.collectAsState()
|
||||
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -323,7 +323,12 @@ private fun RenderSearchResults(
|
||||
key = { _, item -> "u" + item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserComposeForChat(item, accountViewModel) {
|
||||
nav("Room/${item.pubkeyHex}")
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val withKey = ChatroomKey(persistentSetOf(item.pubkeyHex))
|
||||
accountViewModel.userProfile().createChatroom(withKey)
|
||||
nav("Room/${withKey.hashCode()}")
|
||||
}
|
||||
|
||||
searchBarViewModel.clear()
|
||||
}
|
||||
}
|
||||
@@ -347,18 +352,22 @@ private fun RenderChannel(
|
||||
item: com.vitorpamplona.amethyst.model.Channel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val hasNewMessages = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = item.idHex,
|
||||
channelPicture = item.profilePicture(),
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${item.info.name}",
|
||||
item.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.info.about,
|
||||
false,
|
||||
channelLastContent = item.summary(),
|
||||
hasNewMessages,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
@@ -384,7 +393,7 @@ fun UserComposeForChat(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseUser, 55.dp, accountViewModel)
|
||||
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -7,14 +7,15 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -25,14 +26,12 @@ 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.Channel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) {
|
||||
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) {
|
||||
val postViewModel: NewChannelViewModel = viewModel()
|
||||
postViewModel.load(accountViewModel.account, channel)
|
||||
|
||||
@@ -44,7 +43,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
|
||||
) {
|
||||
Surface() {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -52,19 +51,15 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
}
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
postViewModel.channelName.value.text.isNotBlank()
|
||||
)
|
||||
|
||||
@@ -3,18 +3,21 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NewChannelViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var originalChannel: Channel? = null
|
||||
private var originalChannel: PublicChatChannel? = null
|
||||
|
||||
val channelName = mutableStateOf(TextFieldValue())
|
||||
val channelPicture = mutableStateOf(TextFieldValue())
|
||||
val channelDescription = mutableStateOf(TextFieldValue())
|
||||
|
||||
fun load(account: Account, channel: Channel?) {
|
||||
fun load(account: Account, channel: PublicChatChannel?) {
|
||||
this.account = account
|
||||
if (channel != null) {
|
||||
originalChannel = channel
|
||||
@@ -25,24 +28,26 @@ class NewChannelViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun create() {
|
||||
this.account?.let { account ->
|
||||
if (originalChannel == null) {
|
||||
account.sendCreateNewChannel(
|
||||
channelName.value.text,
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text
|
||||
)
|
||||
} else {
|
||||
account.sendChangeChannel(
|
||||
channelName.value.text,
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text,
|
||||
originalChannel!!
|
||||
)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account?.let { account ->
|
||||
if (originalChannel == null) {
|
||||
account.sendCreateNewChannel(
|
||||
channelName.value.text,
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text
|
||||
)
|
||||
} else {
|
||||
account.sendChangeChannel(
|
||||
channelName.value.text,
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text,
|
||||
originalChannel!!
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear()
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user