Compare commits
898 Commits
tyiu/fix-l
...
tyiu/auto-
| Author | SHA1 | Date | |
|---|---|---|---|
|
e6bf6a6b8a
|
|||
|
|
91fc0039eb | ||
|
|
8eb013f1f7 | ||
|
|
4f459d128a | ||
|
|
58e53631c6 | ||
|
|
7b2e178f5b | ||
|
|
da99130b78 | ||
|
|
b79d361016 | ||
|
|
4889c0a7d9 | ||
|
|
61c9732acd | ||
|
|
ee6c080af8 | ||
|
|
a18ba86157 | ||
|
|
03931ef70e | ||
|
|
3284832eb0 | ||
|
|
c679be9644 | ||
| 9f701a7d44 | |||
|
|
cb11087034 | ||
|
|
49b7aee74e | ||
|
|
5b97906138 | ||
|
|
c74d3e4938 | ||
|
|
df6911f9cb | ||
|
|
1ca0519e25 | ||
|
|
c87f19b479 | ||
|
|
68ed3d7796 | ||
|
|
5c885b0fd4 | ||
| 8589fe9aee | |||
|
|
2aee84c65f | ||
|
|
76c6ac0f0b | ||
|
|
be08083b88 | ||
|
|
c2325a5e39 | ||
|
|
3eb544e40d | ||
|
|
9d209f485c | ||
|
|
1394122542 | ||
|
|
e89c025d9d | ||
|
|
0970c364b6 | ||
|
|
d16192e845 | ||
|
|
3b50f82094 | ||
|
|
46b53e1326 | ||
|
|
225a028f3e | ||
|
|
d074d092a2 | ||
|
|
633fcd69a8 | ||
|
|
67869394cb | ||
|
|
22876b5c28 | ||
|
|
6f7d6d1933 | ||
|
|
fddd86b207 | ||
|
|
95f1127b74 | ||
|
|
4c82176466 | ||
|
|
e2d55ddae4 | ||
|
|
8fa80b7921 | ||
|
|
732b484faf | ||
|
|
cd9c705221 | ||
|
|
ba5a062829 | ||
|
|
687d1c9a3e | ||
|
|
adef207018 | ||
|
|
a050a5b729 | ||
|
|
6bced24430 | ||
|
|
60cddf2a15 | ||
|
|
c9568fe7ac | ||
|
|
5c0e4599ad | ||
|
|
e3519c51a5 | ||
|
|
ed1aa246c4 | ||
|
|
532647d273 | ||
|
|
dab8f7ca61 | ||
|
|
390eb342f7 | ||
|
|
f1a7c0eded | ||
|
|
ed058afc3b | ||
|
|
0e94c48e26 | ||
|
|
6ac68b5a73 | ||
|
|
2048e68d67 | ||
|
|
624d9662d7 | ||
|
|
88db9de4ea | ||
|
|
d667a9d8f7 | ||
|
|
65f3c76eca | ||
|
|
aed1e543d3 | ||
|
|
65576424fd | ||
|
|
f99ad8fffa | ||
|
|
987d173529 | ||
|
|
eab7a91f01 | ||
|
|
2d045f4dfb | ||
|
|
835e5a438f | ||
|
|
ca4e91564a | ||
|
8733cbd42c
|
|||
|
|
f5cdd4a159 | ||
|
|
c4f41220e5 | ||
|
|
0f119d34e6 | ||
|
|
ea90fb0429 | ||
|
|
8227be1873 | ||
|
|
cbd92539a6 | ||
|
|
7940e6fd32 | ||
|
|
07f8ad75dc | ||
|
|
018bb4c33b | ||
|
|
c81b403817 | ||
|
|
e54ce88a3b | ||
|
|
84f4f1c71c | ||
| e934c2bb11 | |||
|
|
fd8ad494e9 | ||
|
|
f14ba7cce4 | ||
|
|
055b13c1cd | ||
|
|
357e8adf86 | ||
|
|
a82a78c7df | ||
|
|
084c86eb0e | ||
|
|
10d9d23b7b | ||
|
|
50ecff0ec6 | ||
|
|
5ae96ec80a | ||
|
|
abfd48ca20 | ||
|
|
67326e2003 | ||
|
|
306c3fe75c | ||
|
|
08c2056290 | ||
|
|
bc5ee7cd51 | ||
|
|
b2d1ad2537 | ||
|
|
b9f37697d7 | ||
|
|
58e88262b0 | ||
|
|
26e28dd3dd | ||
|
|
6ac6ea3cd7 | ||
|
|
2de75968fb | ||
|
|
37b99983d3 | ||
|
|
f62dc9348a | ||
|
|
6aab705399 | ||
|
|
f7da481c68 | ||
|
|
e5b629742a | ||
|
|
a0e6aa060b | ||
|
|
6394f96ac0 | ||
|
|
b6d6af12b8 | ||
|
|
df84c4a64b | ||
|
|
71b333a18a | ||
|
|
29936f7b06 | ||
|
|
1cc1bfbbef | ||
|
|
2aa39e775e | ||
|
|
8a33243c98 | ||
|
|
d198e69dc9 | ||
|
|
c26b30f3c0 | ||
|
|
c2479df213 | ||
|
|
30d045b1c5
|
||
|
|
d76f7564ef
|
||
|
|
bab72b215d
|
||
|
|
d8cd81deb8
|
||
|
|
92dfdacf97
|
||
|
|
ead6e96613
|
||
|
|
7312ee3884
|
||
|
|
45099c59db
|
||
|
3440c828e3
|
|||
|
|
00aa897f05 | ||
|
|
71a1a6f0a3 | ||
|
|
9d22f40a53 | ||
|
|
0bd40c0018 | ||
|
|
ec75769a0f | ||
|
|
47e349558c | ||
|
|
aa559b2916 | ||
|
|
9bf8349db6 | ||
|
|
4c44de9276 | ||
|
|
aa5f8d19f7 | ||
|
|
040e452132 | ||
|
|
ff41bb1b35 | ||
|
|
efe231c122 | ||
|
|
0305712f65 | ||
|
|
ba844aec97 | ||
|
|
62eac0032f | ||
|
|
b673e9b43b | ||
|
|
1dd274e07f | ||
|
|
e8aa52efea | ||
|
|
c7f6db84dd | ||
|
|
4a28ddefab | ||
|
|
3f4e37f1f8 | ||
|
|
9dc304aa04 | ||
|
|
d3dd4faa42 | ||
|
|
b40ee38895 | ||
|
|
fb9d1db6e6 | ||
|
|
591cdd478e | ||
|
|
b97204400f | ||
|
|
ebfe00362c | ||
|
|
accafb4cb2 | ||
|
|
20b124aa59 | ||
|
|
057bb2add5 | ||
|
|
c1b31a9938 | ||
|
|
1e532f9e63 | ||
|
|
16a7d5dedf | ||
|
|
8898bffbed | ||
|
|
7583346b06 | ||
|
|
8fbc71a2c2 | ||
|
|
e100f8c313 | ||
|
|
676c6f2afb | ||
|
|
e7c66156d3 | ||
|
|
99816695ae | ||
|
|
8e984ffa98 | ||
|
|
61e4359164 | ||
|
|
71940aaca0 | ||
|
|
042237ace7 | ||
|
|
b9c10d1eb1 | ||
|
|
638b98624e | ||
|
|
c8224f841d | ||
|
|
18439bbdf9 | ||
|
|
1fd7b759b1 | ||
|
|
0642abe064 | ||
|
|
080efda25e | ||
|
|
bbfe5380e0 | ||
|
|
8a20b7e4a7 | ||
|
|
191950a5aa | ||
|
|
ac82f1bc09 | ||
|
|
209f3e8759 | ||
|
|
897621b5ed | ||
| 66641fc9ae | |||
| 681e0f0be9 | |||
|
|
2ff12823f2 | ||
|
|
d6d996e84b | ||
|
|
d1fce5054d | ||
|
|
300cd87fc2 | ||
| bff3c0dd52 | |||
|
|
ddd027141a | ||
|
|
9b3e25dd6d | ||
|
|
1ae6a3d871 | ||
|
|
4821ba61a5 | ||
|
|
15849e290e | ||
|
|
6ed562ed24 | ||
| 93580e5296 | |||
|
|
a5c33e4431 | ||
| ad7a79c2bb | |||
|
|
eaec3ae011 | ||
|
|
5a1b966191 | ||
|
|
a320fae2bc | ||
|
|
8e0136a13a | ||
|
|
8f767b03ae | ||
|
|
209b23674d | ||
|
|
86917dbd69 | ||
|
|
59daf555cd | ||
|
|
916c7885f8 | ||
|
|
252763ea77 | ||
|
|
aa610c18b2 | ||
|
|
1de96a9dc5 | ||
|
|
746a4093de | ||
|
|
5cc9288759 | ||
|
|
3bedc83764 | ||
|
|
eb17e07478 | ||
|
|
b96ae84068 | ||
|
|
af7d4d2c53 | ||
|
d93a0600f3
|
|||
|
|
e9e5756c94
|
||
|
|
0b3cc2092f
|
||
|
|
285ab11324
|
||
|
|
3610a76c55
|
||
|
|
4644c57bf3
|
||
|
|
33887982b0
|
||
|
|
c3d3db352e
|
||
|
|
8f08e5c4c8
|
||
|
|
e00e89c16b
|
||
|
|
9151ef02a0
|
||
|
|
709a707942
|
||
|
|
343b7a2bcc
|
||
|
|
6534ba3bde
|
||
|
|
02fc065005 | ||
|
|
668b0a94df | ||
|
|
ebba9d3004 | ||
|
|
8733a34933 | ||
|
|
0de6cfe344 | ||
|
|
a4d40dbfa6 | ||
|
|
05d332eac3 | ||
|
|
b5a3697d78 | ||
|
|
247270f3d3 | ||
|
|
9327068264 | ||
|
|
c277c14bcd | ||
|
|
e688a691fc | ||
|
|
b470af8f1d | ||
|
|
efd1168217 | ||
|
|
f7a3f9ab76 | ||
|
|
95041600dc | ||
|
|
8a8d2ebbc3 | ||
|
|
fad0a6b783 | ||
|
|
f5d7465368 | ||
|
|
8a785559c6 | ||
|
|
d4c8c15cc3 | ||
|
|
41a462871c | ||
|
|
76a669acc2 | ||
|
|
39236dc094 | ||
|
|
5860125802 | ||
|
|
ae96c3b707 | ||
|
|
136f6f37e8 | ||
|
|
21f84f722b | ||
|
|
47747379ee | ||
|
|
a1b95d40e6 | ||
|
|
0fc69d862a | ||
|
fb0330476d
|
|||
|
|
4b978594fa
|
||
|
|
f0bbba7a33 | ||
|
|
b5faae9d1c | ||
|
|
a4d4954abd | ||
|
|
735376b00f | ||
|
|
042e02d2e4 | ||
|
|
40468b1603 | ||
|
|
8c19ec1532 | ||
| 1ac9620242 | |||
|
|
d5ecc9bce4 | ||
|
|
d82b69aac5 | ||
|
|
bad6ba3643 | ||
|
|
5c131e62d7 | ||
|
|
29ab48287f | ||
|
|
5c854519db | ||
|
|
2cc04e24a3 | ||
|
|
d24bea366d | ||
|
|
85ce8cb93c | ||
|
32bb8c365d
|
|||
|
|
d9285ab3ca
|
||
|
3cba771655
|
|||
|
f6f2517fda
|
|||
|
|
047325e6b2
|
||
|
|
ba2108d659
|
||
|
|
863c7baa8b
|
||
|
|
8a5e95e47a
|
||
|
|
de0997216d
|
||
|
|
cc64c82ec4
|
||
|
|
e2ca02399b
|
||
|
|
5418f55cee
|
||
|
|
eb65d473cd
|
||
|
|
dd337c4805
|
||
|
|
2f6ed72f6d
|
||
|
|
d71bb33408
|
||
|
|
72cfb2b071
|
||
|
|
a67cb2df90
|
||
|
|
23e9ce1455 | ||
|
|
5f1132cbc8 | ||
|
|
806c6257df | ||
|
|
18aafb086e | ||
|
|
fc534ea42d | ||
|
|
54c8958250 | ||
|
|
e9f71ed07c | ||
|
|
c719058487 | ||
|
|
ab853c406c | ||
|
|
8a88824677 | ||
|
|
0b3918710a | ||
|
|
1320ff6bec | ||
|
|
10cab37270 | ||
|
|
179da97090 | ||
|
|
2b2d124495 | ||
|
|
2a2af056eb | ||
|
|
f56edd5547 | ||
|
|
b30d0c01db | ||
|
|
e8d63768c1 | ||
|
|
1f648057d5 | ||
|
|
ea14099b62 | ||
|
|
f5942f5123 | ||
|
|
cb8585e4f8 | ||
|
|
526689c742 | ||
|
|
32a9856e2a | ||
|
|
6082a2829f | ||
|
|
b3f6b451bf | ||
|
|
91d51c5e76 | ||
|
|
3262fe806a | ||
|
|
d04e9c9b5f | ||
|
|
015eb5f9fe | ||
|
|
4a525a7581 | ||
|
|
72e14fc3a8 | ||
|
|
af275965ee | ||
|
|
9f5913828a | ||
|
|
c7c21cdee7 | ||
|
|
44a2c4ba7b | ||
|
|
a74aea9d12 | ||
|
|
0866c70346 | ||
|
|
2b34e88a47 | ||
|
|
2aa8d527b9 | ||
|
|
88cbb55953 | ||
|
|
e738c6c1ca | ||
|
|
f4e0c8df5c | ||
|
|
269269d056 | ||
|
|
fd49539615 | ||
|
|
dbb5c19002 | ||
|
|
6961113734 | ||
|
|
4642656ce2 | ||
|
|
0c2132b122 | ||
|
|
a1311a940a | ||
|
|
af18975240 | ||
|
|
f500da03e8 | ||
|
|
8a230861bf | ||
|
|
13354b0eb5 | ||
|
|
c6f4643b5a | ||
|
|
ee34b1c0a3 | ||
|
|
a2cd51b6e7 | ||
|
|
831a409fe6 | ||
|
|
11e22628bb | ||
|
|
6598b5b4bb
|
||
|
|
ca293ef29b
|
||
|
|
8051324d3e
|
||
|
|
a11cf66088
|
||
|
|
a10142d3b9
|
||
|
|
83fcd8600a
|
||
|
|
6283157ef4
|
||
|
|
8b2f45da41
|
||
|
|
ed467a2f79
|
||
|
|
feace9b70d
|
||
|
|
62bccd6b60
|
||
|
|
6f4d12cab4
|
||
|
|
436fe830c7
|
||
|
|
69b6d54b0e
|
||
|
|
13522028ba
|
||
|
|
1459581ec9
|
||
|
|
31aedd2a6e
|
||
|
|
bb1dff13ce
|
||
|
|
3b8c884b30
|
||
|
|
30299fafed
|
||
|
|
46c208c9a5 | ||
|
|
8e78bf9e1a | ||
|
|
82fff4591c | ||
|
|
b8226d674d | ||
| 3f3892ba1d | |||
|
|
1b6224e665 | ||
|
|
782f8d6a69 | ||
|
|
9ad6af0e4f | ||
|
|
e16ea0f4dc | ||
|
|
5b1808d8e7 | ||
|
|
d50b2deb26 | ||
|
|
9de4730e17 | ||
|
|
382265dd39 | ||
|
6c7f8cdbe5
|
|||
|
|
36a92b3795 | ||
|
|
6d4d218c28 | ||
|
|
8679da1275
|
||
|
|
59dcbc130c
|
||
|
|
badd3210e5
|
||
|
|
dd6cc3cf4f
|
||
|
|
36a0ca9c80
|
||
|
|
a7d3b19665
|
||
|
|
226e567987
|
||
|
|
898ffc0186
|
||
|
|
4d0f7b576b
|
||
|
|
230d049384
|
||
|
|
774a4173b0
|
||
|
|
f8b5d91720
|
||
|
|
0832c82ee8
|
||
|
|
4786c6f0cb | ||
|
|
8d0aea22fd | ||
|
|
3d27e49e70 | ||
|
|
e9be227009 | ||
|
|
2e640db012 | ||
|
|
eed16449fe | ||
|
|
3661d64450 | ||
|
|
24c82293b3 | ||
|
|
7fb1bc48c4 | ||
|
|
2ae4a156da | ||
|
|
f700dd799f | ||
|
|
00548adc1f | ||
|
|
7c08d4af45 | ||
|
|
c7bf1da797 | ||
|
|
c5341ba337 | ||
|
|
95fb7bccf8 | ||
|
|
6499738994 | ||
|
|
532eaa35bf | ||
|
9c1bb30f5f
|
|||
|
|
4683b417b9
|
||
|
|
2176b58215
|
||
|
|
3c7627bb9a
|
||
|
|
463ca5ce7b
|
||
|
|
b6a0626890
|
||
|
|
dbfad189cb
|
||
|
|
8159213ef0
|
||
|
|
684841c03a
|
||
|
|
e429bd7df9
|
||
|
|
45ebcf565a
|
||
|
|
c031b08c9a
|
||
|
|
d98503732e
|
||
|
|
bfe0a39370
|
||
|
|
36de599326
|
||
|
|
3cbdfb7ddf
|
||
|
|
ddd309634f
|
||
|
|
6bbf56f80c
|
||
|
|
de1332d6ea
|
||
|
|
648f25ea7a
|
||
|
|
b46dc04ae8
|
||
|
|
8cabfaf90e
|
||
|
|
0b02956bb7
|
||
|
|
4abea02d6e
|
||
|
|
5d20c49f7d
|
||
|
|
c11e0fa919
|
||
|
|
54bfeb04da
|
||
|
|
214eec9cb6
|
||
|
|
b48de9525e
|
||
|
|
690f3ec740
|
||
|
6df4ffc1f0
|
|||
|
|
83cead1fbb
|
||
|
|
478600cf0e
|
||
|
|
75002802fa
|
||
|
|
faf072d432
|
||
|
|
e888da26a7
|
||
|
|
b532dc48e1 | ||
|
|
7bf18336d0 | ||
|
|
b131c74ee3 | ||
|
|
b9fed6e4eb | ||
|
|
6910e84fd8 | ||
|
|
5f0c1a59e6 | ||
|
|
d8a9efc6e2 | ||
|
|
185732a633 | ||
|
|
b585e8c21c | ||
|
|
b14858b4b1 | ||
|
|
f236ea52e1 | ||
|
|
9c8391b33b | ||
|
|
89b2382ad7 | ||
|
|
2ce0a771ea | ||
|
|
0436c68ec5 | ||
|
|
88740a2bc7 | ||
|
|
e3f578e48a | ||
|
|
39788bd590 | ||
|
|
8c2c8e283f | ||
|
|
8a473885c7 | ||
|
|
ba1c2cd2b9 | ||
|
|
bedf7e0648 | ||
|
|
14802e334d | ||
|
|
98d11fb71e | ||
|
|
9ca959d8d3 | ||
|
|
584a20ade1 | ||
|
|
67041b22f4 | ||
|
|
825a2f944f | ||
|
|
1aa70efee0 | ||
|
|
e2812a9aa1 | ||
|
|
679779ab3e | ||
|
|
98a630248e | ||
|
2f4e33fc9f
|
|||
|
|
ddf60e78b9 | ||
|
|
08badfc746
|
||
|
|
6dfc39e1ed
|
||
|
|
6655b6ac9d
|
||
|
|
c81c14a761
|
||
|
|
3da12e708f | ||
|
|
3d97dc593e | ||
|
|
c6e5193111 | ||
|
|
61ef709d91 | ||
|
|
f6133814e8 | ||
|
|
1701dbdfb9 | ||
|
|
8fe8611527 | ||
|
|
41f692a0c4 | ||
|
|
e0d4841147 | ||
|
|
1bf171a09e | ||
|
|
c5f3a8e509 | ||
|
|
02c973fbb2 | ||
|
|
4996272942 | ||
|
|
094f63bcff | ||
|
|
f9339440f2 | ||
|
|
997bc38885 | ||
|
|
b9d62e300b | ||
|
|
1282b8f5f6 | ||
|
|
127f1e07e3 | ||
|
|
cc190c3618 | ||
|
|
c9c51c6d4a | ||
|
|
b63159a29f | ||
|
|
647c6f8428 | ||
|
|
c8f5a2cffc | ||
|
|
65e22ea0dc
|
||
|
|
e6ce0ef8a3
|
||
|
|
5c949ea0c2
|
||
|
|
4881d41831
|
||
|
|
af7177c436
|
||
|
|
26cf0380c0
|
||
|
|
499e3f6fd4
|
||
|
|
82b237754a
|
||
|
|
0608222cb0 | ||
|
|
b83204898b | ||
|
|
0dd74fde7f | ||
|
|
7a55ea13e3 | ||
|
|
2815827b9f | ||
|
|
4ac3e71039 | ||
|
|
2596542cb6 | ||
|
|
915f3901a7 | ||
|
|
39f39c7382 | ||
|
|
f2ce146e98 | ||
|
|
9ba3543d91 | ||
|
|
dec5de83b9 | ||
|
|
ab6ec18e35 | ||
| 35ee2f5744 | |||
|
|
42e6281e9b | ||
| ae82114a33 | |||
|
|
87d1cd9095 | ||
|
|
2d679bf087 | ||
|
|
aaa6eb013a | ||
|
|
fce689f0ef | ||
|
|
89f33be4cf | ||
|
|
96b6929235 | ||
|
|
a811a12da3 | ||
|
|
8edb1f289d | ||
|
|
cdbd8f2722 | ||
|
|
069a8f52ca | ||
|
|
7664985768 | ||
|
|
a77b0e1bbc | ||
|
|
d8e516841e | ||
|
|
dc33ad962d | ||
|
|
f7eae9275f | ||
|
|
e5adbb2fee | ||
|
|
88d26dc4c2 | ||
|
|
4d71297941 | ||
|
|
21ba936c72 | ||
|
|
04fa8f1805 | ||
|
|
3076e6ee7f | ||
|
08b7e50bd8
|
|||
|
|
5a238502cb | ||
|
|
b0aac1fc42 | ||
|
|
72b51a81de | ||
|
|
8ec1fa29b1 | ||
|
|
81683f980a | ||
|
|
b9fc3f90d1 | ||
|
|
695699aa10 | ||
|
|
0a4e75bfec | ||
|
|
9fef2f071a | ||
|
|
c03b4cac11 | ||
|
|
b773df1204 | ||
|
|
c7a34379dd | ||
|
|
eabf37e35c | ||
|
|
e11147b217 | ||
|
|
7674f42596 | ||
|
|
8c37c8f008 | ||
|
|
74dbbcf1a2 | ||
|
|
e3283fc8f8 | ||
|
|
54fdcd1c84 | ||
|
|
5e0ff1a6a0 | ||
| 6517dcba3f | |||
|
|
63e28d4d79 | ||
| e5c0400b54 | |||
|
|
c6c47e824a | ||
|
866e93d338
|
|||
|
f75fc7eebe
|
|||
|
|
d19596c17e | ||
|
|
0b40cd127c | ||
|
|
754ee254e9 | ||
|
|
963cb37762 | ||
|
|
00da97307e | ||
|
|
312c798bb5 | ||
|
|
7110650267 | ||
|
|
242c1011d9 | ||
|
|
e203eece85 | ||
|
|
1b60524070 | ||
|
|
d15a2f0401 | ||
|
|
159d0fa2b5 | ||
|
|
61fddf800e | ||
|
|
b6d5b6f45e | ||
|
|
f5ed9cd5d4 | ||
|
|
57006b928b | ||
|
fd596241a2
|
|||
|
|
98f0b2f2d2 | ||
|
|
9a4d93824a | ||
|
|
f76563b354 | ||
|
|
b2ee924692 | ||
|
|
6fc70748fe | ||
|
|
5e972dbf2d | ||
|
|
4ebdd01b6c | ||
|
|
13c0c0d679 | ||
|
|
8297859f18 | ||
|
|
e996d5703b | ||
|
|
dfc397337b | ||
|
|
f84d4516db | ||
|
|
2e34230119 | ||
|
|
cad89525b7 | ||
|
|
d2cf18aeee | ||
|
|
a8ce39fc96 | ||
|
|
ed90139b0c | ||
|
|
022045d916 | ||
|
|
4bda490010 | ||
|
|
97382adb63 | ||
|
|
c582755246 | ||
|
|
44a59e8d57 | ||
|
|
98685645d3 | ||
|
|
14f71f1a1d | ||
|
|
91cb6a6763 | ||
|
|
a65351154b | ||
|
|
2e2b33e21d | ||
|
|
c24b0afb8f | ||
|
|
a357bbe4a6 | ||
|
|
b687006b64 | ||
|
|
1f095b0896 | ||
|
|
4f7ed36a7c | ||
|
|
393809c7d7 | ||
|
|
9091cb1aae | ||
|
|
e78a82e5b7 | ||
|
|
7b0ef5f4a7 | ||
|
|
66a5df68b3 | ||
|
|
fa2344b9ba | ||
|
|
68c018cf44 | ||
|
f367df2225
|
|||
|
|
e0984aab34 | ||
|
|
eabbb12195 | ||
|
|
7b1f4b7701 | ||
|
|
7b6d3ef9df | ||
|
|
bc58686016 | ||
|
|
a574dcb27c | ||
|
|
761982e359 | ||
|
|
57d48a0395 | ||
|
|
4f96c88b9b | ||
|
|
da11bc575a | ||
|
|
cc9532d958 | ||
|
|
35f4e7c78d | ||
|
|
d8c822858a | ||
|
|
ca0c837231 | ||
|
|
38fc5afa44 | ||
| 9b76afae4f | |||
|
|
f911f1646d | ||
|
|
20fd061293 | ||
|
|
3f5262cd5d | ||
|
|
982d15ab4a | ||
|
|
074b6efc0f | ||
|
|
ad0ca6ca1a | ||
|
|
e140cacfdf | ||
|
|
b825aa80d8 | ||
|
|
9ee91553c1 | ||
|
|
7ce862f552 | ||
|
|
231f9d1853 | ||
|
|
63acf11065 | ||
|
|
0502f06ef8 | ||
|
|
d921a40f24 | ||
| 2e82b349b7 | |||
|
|
b0007af030 | ||
|
|
dd5c2d7301 | ||
|
|
27c0fbf453 | ||
|
|
d1ad4dc9ff | ||
|
|
4c58c4ffef | ||
|
|
cb3603fb35 | ||
|
|
6df5288294 | ||
|
|
9e02dac5d0 | ||
|
|
b7d9db5cec | ||
|
|
e46792e596 | ||
|
|
fc65da3473 | ||
|
|
4f15469320 | ||
|
|
3ea3595902 | ||
|
|
3caebd9c63 | ||
|
|
4d4f340ab0 | ||
|
|
6a549e5019 | ||
|
|
52bf47a494 | ||
|
|
aee243d3e0 | ||
|
|
18745403ce | ||
|
|
07a20040a4 | ||
|
|
ef3ef03b7f | ||
|
|
71e3ee4867 | ||
|
|
252a77fd97 | ||
|
|
a611a5d252 | ||
|
|
1533be77d8 | ||
|
|
c05223ca2b | ||
|
|
5d441d3192 | ||
|
|
04bce34297 | ||
|
|
af8ce3d32d | ||
|
|
cabe584938 | ||
|
|
dd511c3061 | ||
|
|
18449c8c0d | ||
|
|
044631b324 | ||
|
|
318b254b5d | ||
|
|
487419d098 | ||
|
|
ba82f19a11 | ||
|
|
cba6b3aef7 | ||
|
|
6872382bb7 | ||
|
|
42ea150d45 | ||
|
|
85f86ee31f | ||
|
|
96decd2392 | ||
|
|
73f7b69654 | ||
|
|
d982bb886e | ||
|
|
9766653969 | ||
|
|
5d91e7e595 | ||
|
|
ae00c103ad | ||
|
|
88aa713729 | ||
|
|
be1c03ad0e | ||
|
|
b2b62828e3 | ||
|
|
d1a77891c7 | ||
|
|
20505236ae | ||
|
|
094ac34135 | ||
|
|
6b6743fcbb | ||
|
|
8059408d5f | ||
|
|
04fa4edad8 | ||
|
|
6fffe250c2 | ||
|
|
1e7d9a6373 | ||
|
|
21989719fc | ||
|
|
d5e4866c55 | ||
|
|
f305df3471 | ||
|
|
21320367b1 | ||
|
|
82723faf33 | ||
|
|
48434f83ae | ||
|
|
083d0fa0e5 | ||
| d5a646f9ce | |||
|
38a1ad7611
|
|||
|
|
9bc3860f00 | ||
|
|
35f5ac04b4 | ||
|
|
75b73718d1 | ||
|
|
29cacebe58 | ||
|
|
84ae914bcc | ||
|
|
ef5f3ae649 | ||
|
|
f8068a42e5 | ||
|
|
bdde33bb51 | ||
|
|
e3b602df13 | ||
|
|
38b17f1acd | ||
|
|
575b91554c | ||
|
|
f36bc84618 | ||
|
|
d54c9b7d12 | ||
|
2c6647c95a
|
|||
|
|
3c2f281c6d | ||
|
|
4ba63b0dbd | ||
|
|
e2df7d5df6 | ||
|
|
0dfea0680f | ||
|
|
6cc34632fd | ||
|
|
dffb60a601 | ||
|
|
df076b03fd | ||
|
|
fc83cd4db7 | ||
|
|
e01761ce72 | ||
|
|
efc50f5b18 | ||
|
|
10c9e8ddbc | ||
|
|
f88718d56e | ||
|
|
b6a7f52596 | ||
|
|
cff98161ee | ||
|
|
8a70240968 | ||
|
|
a4855775ef | ||
|
|
06c2741bf4 | ||
|
|
721bb9abf5 | ||
|
|
89bb293acd | ||
|
|
f9c330aebf | ||
|
|
ffbfcd36f5 | ||
|
|
52f568f9b3 | ||
|
|
1c2a7db328 | ||
|
|
3110abc65b | ||
|
|
a9f62960ec | ||
|
|
150bbb1eb2 | ||
|
|
0aff41d384 | ||
|
|
3fec9dd209 | ||
|
|
a560d50366 | ||
|
|
174f7f6cc5 | ||
| a325a3c064 | |||
|
|
d0a6c2e2e4 | ||
|
|
b58baca227 | ||
|
|
5423704980 | ||
|
|
241ed1041d | ||
|
|
5134004ff7 | ||
|
|
071a4209ea | ||
|
|
7f385b2e7e | ||
|
|
502c4daf6f | ||
|
|
ffe2c7284a | ||
|
|
6b1f57d6d0 | ||
|
|
77f5268336 | ||
| c72c0079cc | |||
|
|
5ab1d6294c | ||
|
|
2f90f2d4b7 | ||
|
|
7c2e8a6cc5 | ||
|
|
1288732e5d | ||
|
|
4a6c6a65ab | ||
|
|
0f29d67e1f | ||
|
|
9fd2f51971 | ||
|
|
386bae64ca | ||
|
|
4b5c217213 | ||
|
240fda2429
|
|||
|
bacd9b3c38
|
|||
|
0152286859
|
|||
|
|
06e9a1b392
|
||
|
|
483730af18
|
||
|
|
23229015a6
|
||
|
|
7ab95583df
|
||
|
|
b7a48a24e9
|
||
|
04028d9cff
|
|||
|
6918fb46cf
|
|||
|
|
2b854ef9b7
|
||
|
|
5eb61f1ac1
|
||
|
|
c3bbf7aa8f
|
||
|
|
1e52d958ac
|
||
|
|
5252e5f5bb
|
||
|
|
71d5625f04
|
||
|
|
990e783c30
|
||
|
|
3602189133
|
||
|
|
3ca9acdf34
|
||
|
|
2036d5843b
|
||
|
|
2d3bd11d56
|
||
|
|
a715987e71
|
||
|
|
0303031445
|
||
|
|
d6ae9a5d79
|
||
|
|
356bd06e6a
|
||
|
|
e757bdca90
|
||
|
|
ff1b4d724d
|
||
|
|
b8614f055c
|
||
|
|
63ab151a5e
|
||
|
|
fd9d4deb44
|
||
|
|
a5c719673e
|
||
|
|
2bf3c6718d
|
||
|
|
3f3e59488a
|
||
|
|
b1b4b5b6c9
|
||
|
|
7455665672
|
||
|
|
9f22234926
|
||
|
|
21a8a4e96f
|
||
|
|
c635f3d77a
|
||
|
|
2e9a4388b9
|
||
|
|
4a1949eeb8
|
||
|
|
b4fcb58bcb
|
||
|
|
7d852eb33b
|
||
|
|
a448f610c0
|
||
|
|
8cc561b8c6
|
||
|
|
01630d0a4c
|
||
|
16156f4d9a
|
|||
|
f840fe9c80
|
|||
|
77bcd1b715
|
|||
|
|
75fd8de456 | ||
|
|
71f7ea47df | ||
|
|
64b1a57918 | ||
|
|
6c63f8f22a | ||
|
|
673358408a | ||
|
|
e4dd585754 | ||
|
|
436d20dfbd | ||
|
|
810b3e1fa5 | ||
|
|
75fb0d19e2 | ||
|
|
a2749eaaaa | ||
|
|
83c9289345 | ||
|
|
4c3a83772e | ||
| 5cd4c2d75e | |||
|
|
85e797a054 | ||
|
|
9f52e2c246 | ||
|
|
0210ae5d61 | ||
|
|
e5749c8748 |
493
CHANGELOG.md
493
CHANGELOG.md
@@ -1,3 +1,475 @@
|
||||
## [1.4.3-15] - 2023-04-29
|
||||
|
||||
### Added
|
||||
|
||||
- Add q tag to quoted renotes (William Casarin)
|
||||
- Add confirmation alert when clearing all bookmarks (Swift)
|
||||
- Show blurhash placeholders from image metadata (William Casarin)
|
||||
- Add image metadata to image uploads (William Casarin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Load zaps instantly on events (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix thread incompatibility for clients that add more than one reply tag (amethyst, plebstr)
|
||||
- Preserve order of bookmarks when saving (William Casarin)
|
||||
- Fix crash when you have invalid relays in your relay list (William Casarin)
|
||||
|
||||
|
||||
|
||||
[1.4.3-14]: https://github.com/damus-io/damus/releases/tag/v1.4.3-14
|
||||
|
||||
## [1.4.3-10] - 2023-04-25
|
||||
|
||||
### Added
|
||||
|
||||
- Add paste button to login (Suhail Saqan)
|
||||
- Add nokyctranslate translation option (symbsrcool)
|
||||
- You can now change the default zap type (William Casarin)
|
||||
- Add partial support for different repost variants (William Casarin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Change 500 custom zap to 420 (William Casarin)
|
||||
- New looks to the custom zaps view (ericholguin)
|
||||
- Adjust attachment images placement when posting (Swift)
|
||||
- Only show friends, not friend-of-friend in friend filter (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix reposts on macos and ipad (William Casarin)
|
||||
- Fix slow reconnection issues (Bryan Montz)
|
||||
- Fix issue where uploaded images were from someone else (Swift)
|
||||
- Fix crash with LibreTranslate server setting selection and remove delisted vern server (Terry Yiu)
|
||||
- Fix buggy zap amounts and wallet selector settings (William Casarin)
|
||||
|
||||
|
||||
[1.4.3-10]: https://github.com/damus-io/damus/releases/tag/v1.4.3-10
|
||||
|
||||
## [1.4.3-2] - 2023-04-17
|
||||
|
||||
### Added
|
||||
|
||||
- Add deep links for local notifications (Swift)
|
||||
- Add thread muting (Terry Yiu)
|
||||
- Preview media uploads when posting (Swift)
|
||||
- Add QR Code in profiles (ericholguin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Always check signatures of profile events (William Casarin)
|
||||
- Ask permission before uploading media (Swift)
|
||||
- Show DM message in local notification (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed repost turning green too early and not reposting sometimes (Swift)
|
||||
- Fix shuffling when choosing users to reply to (Joshua Jiang)
|
||||
- Do not translate own notes if logged in with private key (Terry Yiu)
|
||||
- Load missing profiles from boosts on home view (Gísli Kristjánsson)
|
||||
- Load missing profiles from boosts on profile view (Gísli Kristjánsson)
|
||||
- Fix tap area when mentioning users (OlegAba)
|
||||
- Fix invalid DM author notifications (William Casarin)
|
||||
- Fix relay signal indicator, properly show how many relays you are connected to (William Casarin)
|
||||
|
||||
|
||||
[1.4.3-2]: https://github.com/damus-io/damus/releases/tag/v1.4.3-2
|
||||
|
||||
## [1.4.2-2] - 2023-04-12
|
||||
|
||||
### Added
|
||||
|
||||
- Include #btc in custom #bitcoin hashtag (William Casarin)
|
||||
- Make notification dots configurable (William Casarin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Display follows in most recent to oldest (Luis Cabrera)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix hitches caused by syncronous loading of cached images (William Casarin)
|
||||
- Fix tabs sometimes not switching (William Casarin)
|
||||
|
||||
|
||||
[1.4.2-2]: https://github.com/damus-io/damus/releases/tag/v1.4.2-2
|
||||
|
||||
## [1.4.1-8] - 2023-04-10
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for nostr: bech32 urls in posts and DMs (NIP19) (Bartholomew Joyce)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Don't leak mentions in DMs (William Casarin)
|
||||
- Fix tap area when mentioning users (OlegAba)
|
||||
|
||||
[1.4.1-8]: https://github.com/damus-io/damus/releases/tag/v1.4.1-8
|
||||
## [1.4.1-7] - 2023-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- Add #zap and #zapathon custom hashtags (William Casarin)
|
||||
- Add custom #plebchain icon (William Casarin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Add validation to prevent whitespaces be inputted on NIP-05 input field (Terry Yiu)
|
||||
- Change reply color from blue to purple. Blue is banned from Damus. (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix padding in post view (OlegAba)
|
||||
- Show most recently bookmarked notes at the top (Bryan Montz)
|
||||
|
||||
|
||||
[1.4.1-7]: https://github.com/damus-io/damus/releases/tag/v1.4.1-7
|
||||
|
||||
## [1.4.1-6] - 2023-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- Custom hashtags for #bitcoin, #nostr and #coffeechain (William Casarin)
|
||||
|
||||
### Changed
|
||||
|
||||
- Disable translations in DMs by default (William Casarin)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Don't show Translating... if we're not actually translating (William Casarin)
|
||||
|
||||
|
||||
[1.4.1-6]: https://github.com/damus-io/damus/releases/tag/v1.4.1-6
|
||||
|
||||
## [1.4.1-4] - 2023-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- Cache translations (William Casarin)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix translation text popping (William Casarin)
|
||||
- Fix broken auto-translations (William Casarin)
|
||||
- Fix extraneous padding on some image posts (William Casarin)
|
||||
- Fix crash in relay list view (William Casarin)
|
||||
|
||||
[1.4.1-4]: https://github.com/damus-io/damus/releases/tag/v1.4.1-4
|
||||
|
||||
## [1.4.1-3] - 2023-04-05
|
||||
|
||||
### Added
|
||||
|
||||
- Added text truncation settings (William Casarin)
|
||||
|
||||
### Changed
|
||||
|
||||
- Rename block to mute (William Casarin)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduce chopping of images (mainvolume)
|
||||
- Fix some notification settings not saving (William Casarin)
|
||||
- Fix broken camera uploads (again) (Joel Klabo)
|
||||
|
||||
|
||||
[1.4.1-3]: https://github.com/damus-io/damus/releases/tag/v1.4.1-3
|
||||
|
||||
## [1.4.1-2] - 2023-04-04
|
||||
|
||||
### Added
|
||||
|
||||
- Reply counts (William Casarin)
|
||||
- Add option to only show notification from people you follow (Swift)
|
||||
- Added local notifications for other events (Swift)
|
||||
- Show a custom view when tagged user isn't found (ericholguin)
|
||||
- Show referenced notes in DMs (William Casarin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Show full bleed images on selected events in threads (William Casarin)
|
||||
- Improvement to square image displaying (mainvolume)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix broken website links that have missing https:// prefixes (William Casarin)
|
||||
- Get around CCP bootstrap relay banning by caching user's relays as their bootstrap relays (William Casarin)
|
||||
|
||||
|
||||
[1.4.1-2]: https://github.com/damus-io/damus/releases/tag/v1.4.1-2
|
||||
|
||||
## [1.4.1] - 2023-04-03
|
||||
|
||||
### Added
|
||||
|
||||
- Profile Picture Upload (Joel Klabo)
|
||||
- Enable offline posting (William Casarin)
|
||||
- Add auto-translation caching to ruduce api usage (Terry Yiu)
|
||||
- Added support for gif uploads (Swift)
|
||||
- Add a Divider in the Follows List for Large Screens (Joel Klabo)
|
||||
- Upload Photos and Videos from Camera (Joel Klabo)
|
||||
- Added ability to lookup users by nip05 identifiers (William Casarin)
|
||||
|
||||
### Changed
|
||||
|
||||
- Only truncate timeline text if enabled in settings (William Casarin)
|
||||
- Make mentions wide in notifications like in timeline (William Casarin)
|
||||
- Broadcast events you are replying to (William Casarin)
|
||||
- Broadcast now also broadcasts event user's profile (William Casarin)
|
||||
- Improved look of reply view (ericholguin)
|
||||
- Remove gradient in some places for visibility (ericholguin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix cropped images (mainvolume)
|
||||
- Truncate long text in notification items (William Casarin)
|
||||
- Restore missing reply description on selected events (William Casarin)
|
||||
- Show sent DMs immediately (William Casarin)
|
||||
- Fixed size of translated text (William Casarin)
|
||||
- Fix crash when reposting (William Casarin)
|
||||
- Fix unclickable image dismiss button (OlegAba)
|
||||
|
||||
|
||||
[1.4.1]: https://github.com/damus-io/damus/releases/tag/v1.4.1
|
||||
## [1.4.0] - 2023-03-27
|
||||
|
||||
### Added
|
||||
|
||||
- Local zap notifications (Swift)
|
||||
- Add support for video uploads (Swift)
|
||||
- Auto Translation (Terry Yiu)
|
||||
- Portuguese (Brazil) translations (Andressa Munturo)
|
||||
- Spanish (Spain) translations (Max Pleb)
|
||||
- Vietnamese translations (ShiryoRyo)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed small notification hit boxes (Terry Yiu)
|
||||
|
||||
[1.4.0]: https://github.com/damus-io/damus/releases/tag/v1.4.0
|
||||
|
||||
## [1.3.0-7] - 2023-03-24
|
||||
|
||||
- New experimental timeline view
|
||||
|
||||
[1.3.0-7]: https://github.com/damus-io/damus/releases/tag/v1.3.0-7
|
||||
|
||||
## [1.3.0-6] - 2023-03-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix bug where nostr: links and QRs stopped working (William Casarin)
|
||||
|
||||
|
||||
[1.3.0-6]: https://github.com/damus-io/damus/releases/tag/v1.3.0-6
|
||||
|
||||
## [1.3.0-5] - 2023-03-20
|
||||
|
||||
### Added
|
||||
|
||||
- Add Time Ago to DM View (Joel Klabo)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed internal links opening in other nostr clients (William Casarin)
|
||||
- Remove authentication for copying npub (Swift)
|
||||
|
||||
|
||||
[1.3.0-5]: https://github.com/damus-io/damus/releases/tag/v1.3.0-5
|
||||
|
||||
## [1.3.0-4] - 2023-03-17
|
||||
|
||||
### Changed
|
||||
|
||||
- It's much easier to tag users in replies and posts (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix bug where small black text appears during image upload (William Casarin)
|
||||
|
||||
|
||||
[1.3.0-4]: https://github.com/damus-io/damus/releases/tag/v1.3.0-4
|
||||
|
||||
## [1.3.0-3] - 2023-03-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix image upload url delay after progress bar disappears (William Casarin)
|
||||
- Fix issue where damus stops trying to reconnect (William Casarin)
|
||||
|
||||
[1.3.0-3]: https://github.com/damus-io/damus/releases/tag/v1.3.0-3
|
||||
|
||||
## [1.3.0-2] - 2023-03-16
|
||||
|
||||
### Added
|
||||
|
||||
- Add image uploader (Swift)
|
||||
- Add option to always show images (never blur) (William Casarin)
|
||||
- Canadian French (Pierre - synoptic_okubo)
|
||||
- Hungarian translations (Zoltan)
|
||||
- Korean translations (sogoagain)
|
||||
- Swedish translations (Pextar)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed embedded note popping (William Casarin)
|
||||
- Bump notification limit from 100 to 500 (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix zap button preventing scrolling (William Casarin)
|
||||
|
||||
|
||||
[1.3.0-2]: https://github.com/damus-io/damus/releases/tag/v1.3.0-2
|
||||
|
||||
## [1.3.0] - 2023-03-15
|
||||
|
||||
### Added
|
||||
|
||||
- Extend user tagging search to all local profiles (William Casarin)
|
||||
- Vibrate when a zap is received (Swift)
|
||||
- New and Improved Share sheet (ericholguin)
|
||||
- Bulgarian translations (elsat)
|
||||
- Persian translations (Mahdi Taghizadeh)
|
||||
- Ukrainian translations (Valeriia Khudiakova, Tony B)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Reduce battery usage by using exp backoff on connections (Bryan Montz)
|
||||
- Don't show both realname and username if they are the same (William Casarin)
|
||||
- Show error on invalid lightning tip address (Swift)
|
||||
- Make DM Content More Visible (Joel Klabo)
|
||||
- Remove spaces from hashtag searches (gladiusKatana)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show @ mentions for users with display_names and no username (William Casarin)
|
||||
- Make user search case insensitive (William Casarin)
|
||||
- Fix repost button sometimes not working (OlegAba)
|
||||
- Don't show follows you for your own profile (benthecarman)
|
||||
- Fix json appearing in profile searches (gladiusKatana)
|
||||
- Fix unexpected font size when posting (Bryan Montz)
|
||||
- Fix keyboard sticking issues (OlegAba)
|
||||
- Fixed tab bar background color on macOS (Joel Klabo)
|
||||
- Fix some links getting interpreted as images (gladiusKatana)
|
||||
|
||||
|
||||
[1.3.0]: https://github.com/damus-io/damus/releases/tag/v1.3.0
|
||||
|
||||
## [1.2.0-4] - 2023-03-05
|
||||
|
||||
### Added
|
||||
|
||||
- Add ellipsis button to notes (ericholguin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Immediately search for events and profiles (William Casarin)
|
||||
- Use long-press for custom zaps (William Casarin)
|
||||
- Make shaka animation smoother (Swift)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed hit detection bugs on profile page (OlegAba)
|
||||
- Fix disappearing text on Thread view (Bryan Montz)
|
||||
- Render links in notification summaries (Joel Klabo)
|
||||
- Don't show notifications from ourselves (William Casarin)
|
||||
- Fix issue where navbar back button would show the wrong text (Jack Chakany)
|
||||
- Fix case sensitivity when searching hashtags (randymcmillan)
|
||||
- Fix issue where opening reposts shows json (William Casarin)
|
||||
|
||||
|
||||
[1.2.0-4]: https://github.com/damus-io/damus/releases/tag/v1.2.0-4
|
||||
|
||||
## [1.2.0-3] - 2023-03-04
|
||||
|
||||
### Added
|
||||
|
||||
- Add additional info to recommended relay view (ericholguin)
|
||||
- Add shaka animation (Swift)
|
||||
- Add option to disable image animation (OlegAba)
|
||||
- Add additional warning when deleting account (ericholguin)
|
||||
- Threads now load instantly and are cached (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Wrap long profile display names (OlegAba)
|
||||
- Fixed weird scaling on profile pictures (OlegAba)
|
||||
- Fixed width of copy pubkey on profile page (Joel Klabo)
|
||||
- Make damus purple use more consistent in mentions (Joel Klabo)
|
||||
|
||||
|
||||
|
||||
[1.2.0-3]: https://github.com/damus-io/damus/releases/tag/v1.2.0-3
|
||||
|
||||
## [1.1.0-10] - 2023-03-01
|
||||
|
||||
### Added
|
||||
|
||||
- Truncate large posts and add a show more button (OlegAba)
|
||||
- Private Zaps (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix default zap amount setting not getting updated (William Casarin)
|
||||
- Fix issue where keyboard covers custom zap comment (William Casarin)
|
||||
|
||||
|
||||
[1.1.0-10]: https://github.com/damus-io/damus/releases/tag/v1.1.0-10
|
||||
|
||||
## [1.1.0-9] - 2023-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- Customized zaps (William Casarin)
|
||||
- Add new Notifications View (William Casarin)
|
||||
- Bookmarking (Joel Klabo)
|
||||
- Chinese, Traditional (Hong Kong) translations (rasputin)
|
||||
- Chinese, Traditional (Taiwan) translations (rasputin)
|
||||
|
||||
### Changed
|
||||
|
||||
- No more inline npubs when tagging users (Swift)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix alignment of side menu labels (Joel Klabo)
|
||||
- Fix duplicated participants in reply-to view (Joel Klabo)
|
||||
- Load missing profiles in Zaps view (William Casarin)
|
||||
- Fix memory leak with inline videos (William Casarin)
|
||||
- Eliminate popping when scrolling (William Casarin)
|
||||
|
||||
|
||||
[1.1.0-9]: https://github.com/damus-io/damus/releases/tag/v1.1.0-9
|
||||
|
||||
## [1.1.0-3] - 2023-02-20
|
||||
|
||||
### Added
|
||||
@@ -6,6 +478,9 @@
|
||||
- Added the ability to select text on posts (OlegAba)
|
||||
- Added Posts or Post & Replies selector to Profile (ericholguin)
|
||||
- Improved profile navbar (OlegAba)
|
||||
- Czech translations (Martin Gabrhel)
|
||||
- Indonesian translations (johnybergzy)
|
||||
- Russian translations (Tony B)
|
||||
|
||||
|
||||
### Changed
|
||||
@@ -54,7 +529,6 @@
|
||||
### Added
|
||||
|
||||
- Relay Filtering (William Casarin)
|
||||
- Japanese translations (Terry Yiu)
|
||||
- Add password autofill on account login and creation (Terry Yiu)
|
||||
- Show if relay is paid (William Casarin)
|
||||
- Add "Follows You" indicator on profile (William Casarin)
|
||||
@@ -67,6 +541,10 @@
|
||||
- Copy invoice button (Joel Klabo)
|
||||
- Receive Lightning Zaps (William Casarin)
|
||||
- Allow text selection in bio (Suhail Saqan)
|
||||
- Chinese, Simplified (China mainland) translations (haolong, rasputin)
|
||||
- Dutch translations (Heimen Stoffels - Vistaus)
|
||||
- Greek translations (milicode)
|
||||
- Japanese translations (akiomik, foxytanuki, Guetsu Ren - Nighthaven, h3y6e, middlingphys)
|
||||
|
||||
|
||||
### Changed
|
||||
@@ -101,6 +579,7 @@
|
||||
- LibreTranslate note translations (Terry Yiu)
|
||||
- Added support for account deletion (William Casarin)
|
||||
- User tagging and autocompletion in posts (Swift)
|
||||
- Polish translations (pysiak)
|
||||
|
||||
|
||||
### Changed
|
||||
@@ -123,7 +602,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added Arabic and Portuguese translations (Barodane, Antonio Chagas)
|
||||
- Arabic translations (Barodane)
|
||||
- Portuguese translations (Antonio Chagas)
|
||||
- Add QRCode view for sharing your pubkey (ericholguin)
|
||||
- Added nostr: uri handling (William Casarin)
|
||||
|
||||
@@ -150,7 +630,8 @@
|
||||
### Added
|
||||
|
||||
- Reposts view (Terry Yiu)
|
||||
- Translations for it_IT, it_CH, fr_FR, de_DE, de_AT and lv_LV (Nicolò Carcagnì, Solobalbo, Gregor, Peter Gerstbach, SYX)
|
||||
- Italian translations (Nicolò Carcagnì)
|
||||
- Latvian translations (SYX)
|
||||
- Added ability to block users (William Casarin)
|
||||
- Added a way to report content (William Casarin)
|
||||
- Stretchable profile cover header (Swift)
|
||||
@@ -177,7 +658,9 @@
|
||||
|
||||
- Show website on profiles (William Casarin)
|
||||
- Add the ability to choose participants when replying (Joel Klabo)
|
||||
- Translations for de_AT, de_DE, tr_TR, fr_FR (Gregor, Peter Gerstbach, Taylan Benli, Solobalbo)
|
||||
- German translations (Gregor, Peter Gerstbach)
|
||||
- Turkish translations (Taylan Benli)
|
||||
- French (France) translations (Solobalbo)
|
||||
- Add DM Message Requests (William Casarin)
|
||||
|
||||
|
||||
@@ -609,5 +1092,3 @@
|
||||
|
||||
|
||||
[0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
dependencies: [
|
||||
.Package(url: "https://github.com/daltoniam/Starscream.git", majorVersion: 4),
|
||||
.Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
|
||||
]
|
||||
|
||||
@@ -92,7 +92,7 @@ damus implements the following [Nostr Implementation Possibilities][nips]
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributors welcome!
|
||||
Contributors welcome! Start by examining known issues: https://github.com/damus-io/damus/issues.
|
||||
|
||||
### Code
|
||||
|
||||
@@ -100,6 +100,11 @@ Contributors welcome!
|
||||
|
||||
[git-send-email]: http://git-send-email.io
|
||||
|
||||
### Privacy
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to, and third party media hosters (e.g. nostr.build, imgur.com, giphy.com, youtube.com etc.) that render on Damus. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
|
||||
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
|
||||
|
||||
### Translations
|
||||
|
||||
Translators welcome! Join the [Transifex][transifex] project.
|
||||
|
||||
@@ -91,13 +91,12 @@ int bech32_encode(char *output, const char *hrp, const uint8_t *data, size_t dat
|
||||
return 1;
|
||||
}
|
||||
|
||||
bech32_encoding bech32_decode(char* hrp, uint8_t *data, size_t *data_len, const char *input, size_t max_input_len) {
|
||||
bech32_encoding bech32_decode_len(char* hrp, uint8_t *data, size_t *data_len, const char *input, size_t input_len) {
|
||||
uint32_t chk = 1;
|
||||
size_t i;
|
||||
size_t input_len = strlen(input);
|
||||
size_t hrp_len;
|
||||
int have_lower = 0, have_upper = 0;
|
||||
if (input_len < 8 || input_len > max_input_len) {
|
||||
if (input_len < 8) {
|
||||
return BECH32_ENCODING_NONE;
|
||||
}
|
||||
*data_len = 0;
|
||||
@@ -154,6 +153,14 @@ bech32_encoding bech32_decode(char* hrp, uint8_t *data, size_t *data_len, const
|
||||
}
|
||||
}
|
||||
|
||||
bech32_encoding bech32_decode(char* hrp, uint8_t *data, size_t *data_len, const char *input, size_t max_input_len) {
|
||||
size_t len = strlen(input);
|
||||
if (len > max_input_len) {
|
||||
return BECH32_ENCODING_NONE;
|
||||
}
|
||||
return bech32_decode_len(hrp, data, data_len, input, len);
|
||||
}
|
||||
|
||||
int bech32_convert_bits(uint8_t* out, size_t* outlen, int outbits, const uint8_t* in, size_t inlen, int inbits, int pad) {
|
||||
uint32_t val = 0;
|
||||
int bits = 0;
|
||||
|
||||
@@ -118,6 +118,14 @@ bech32_encoding bech32_decode(
|
||||
size_t max_input_len
|
||||
);
|
||||
|
||||
bech32_encoding bech32_decode_len(
|
||||
char *hrp,
|
||||
uint8_t *data,
|
||||
size_t *data_len,
|
||||
const char *input,
|
||||
size_t input_len
|
||||
);
|
||||
|
||||
/* Helper from bech32: translates inbits-bit bytes to outbits-bit bytes.
|
||||
* @outlen is incremented as bytes are added.
|
||||
* @pad is true if we're to pad, otherwise truncate last byte if necessary
|
||||
|
||||
56
damus-c/block.h
Normal file
56
damus-c/block.h
Normal file
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// block.h
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-09.
|
||||
//
|
||||
|
||||
#ifndef block_h
|
||||
#define block_h
|
||||
|
||||
#include "nostr_bech32.h"
|
||||
#include "str_block.h"
|
||||
|
||||
#define MAX_BLOCKS 1024
|
||||
|
||||
enum block_type {
|
||||
BLOCK_HASHTAG = 1,
|
||||
BLOCK_TEXT = 2,
|
||||
BLOCK_MENTION_INDEX = 3,
|
||||
BLOCK_MENTION_BECH32 = 4,
|
||||
BLOCK_URL = 5,
|
||||
BLOCK_INVOICE = 6,
|
||||
};
|
||||
|
||||
|
||||
typedef struct invoice_block {
|
||||
struct str_block invstr;
|
||||
union {
|
||||
struct bolt11 *bolt11;
|
||||
};
|
||||
} invoice_block_t;
|
||||
|
||||
typedef struct mention_bech32_block {
|
||||
struct str_block str;
|
||||
struct nostr_bech32 bech32;
|
||||
} mention_bech32_block_t;
|
||||
|
||||
typedef struct block {
|
||||
enum block_type type;
|
||||
union {
|
||||
struct str_block str;
|
||||
struct invoice_block invoice;
|
||||
struct mention_bech32_block mention_bech32;
|
||||
int mention_index;
|
||||
} block;
|
||||
} block_t;
|
||||
|
||||
typedef struct blocks {
|
||||
int num_blocks;
|
||||
struct block *blocks;
|
||||
} blocks_t;
|
||||
|
||||
void blocks_init(struct blocks *blocks);
|
||||
void blocks_free(struct blocks *blocks);
|
||||
|
||||
#endif /* block_h */
|
||||
171
damus-c/cursor.h
Normal file
171
damus-c/cursor.h
Normal file
@@ -0,0 +1,171 @@
|
||||
//
|
||||
// cursor.h
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-09.
|
||||
//
|
||||
|
||||
#ifndef cursor_h
|
||||
#define cursor_h
|
||||
|
||||
#include <ctype.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef unsigned char u8;
|
||||
|
||||
struct cursor {
|
||||
const u8 *p;
|
||||
const u8 *start;
|
||||
const u8 *end;
|
||||
};
|
||||
|
||||
static inline int is_whitespace(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r';
|
||||
}
|
||||
|
||||
static inline int is_boundary(char c) {
|
||||
return !isalnum(c);
|
||||
}
|
||||
|
||||
static inline int is_invalid_url_ending(char c) {
|
||||
return c == '!' || c == '?' || c == ')' || c == '.' || c == ',' || c == ';';
|
||||
}
|
||||
|
||||
static inline int is_alphanumeric(char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
|
||||
}
|
||||
|
||||
static inline void make_cursor(struct cursor *c, const u8 *content, size_t len)
|
||||
{
|
||||
c->start = content;
|
||||
c->end = content + len;
|
||||
c->p = content;
|
||||
}
|
||||
|
||||
static inline int consume_until_boundary(struct cursor *cur) {
|
||||
char c;
|
||||
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
|
||||
if (is_boundary(c))
|
||||
return 1;
|
||||
|
||||
cur->p++;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static inline int consume_until_whitespace(struct cursor *cur, int or_end) {
|
||||
char c;
|
||||
int consumedAtLeastOne = 0;
|
||||
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
|
||||
if (is_whitespace(c))
|
||||
return consumedAtLeastOne;
|
||||
|
||||
cur->p++;
|
||||
consumedAtLeastOne = 1;
|
||||
}
|
||||
|
||||
return or_end;
|
||||
}
|
||||
|
||||
static inline int consume_until_non_alphanumeric(struct cursor *cur, int or_end) {
|
||||
char c;
|
||||
int consumedAtLeastOne = 0;
|
||||
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
|
||||
if (!is_alphanumeric(c))
|
||||
return consumedAtLeastOne;
|
||||
|
||||
cur->p++;
|
||||
consumedAtLeastOne = 1;
|
||||
}
|
||||
|
||||
return or_end;
|
||||
}
|
||||
|
||||
static inline int parse_char(struct cursor *cur, char c) {
|
||||
if (cur->p >= cur->end)
|
||||
return 0;
|
||||
|
||||
if (*cur->p == c) {
|
||||
cur->p++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static inline int peek_char(struct cursor *cur, int ind) {
|
||||
if ((cur->p + ind < cur->start) || (cur->p + ind >= cur->end))
|
||||
return -1;
|
||||
|
||||
return *(cur->p + ind);
|
||||
}
|
||||
|
||||
static int parse_digit(struct cursor *cur, int *digit) {
|
||||
int c;
|
||||
if ((c = peek_char(cur, 0)) == -1)
|
||||
return 0;
|
||||
|
||||
c -= '0';
|
||||
|
||||
if (c >= 0 && c <= 9) {
|
||||
*digit = c;
|
||||
cur->p++;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
static inline int pull_byte(struct cursor *cur, u8 *byte) {
|
||||
if (cur->p >= cur->end)
|
||||
return 0;
|
||||
|
||||
*byte = *cur->p;
|
||||
cur->p++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static inline int pull_bytes(struct cursor *cur, int count, const u8 **bytes) {
|
||||
if (cur->p + count > cur->end)
|
||||
return 0;
|
||||
|
||||
*bytes = cur->p;
|
||||
cur->p += count;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static inline int parse_str(struct cursor *cur, const char *str) {
|
||||
int i;
|
||||
char c, cs;
|
||||
unsigned long len;
|
||||
|
||||
len = strlen(str);
|
||||
|
||||
if (cur->p + len >= cur->end)
|
||||
return 0;
|
||||
|
||||
for (i = 0; i < len; i++) {
|
||||
c = tolower(cur->p[i]);
|
||||
cs = tolower(str[i]);
|
||||
|
||||
if (c != cs)
|
||||
return 0;
|
||||
}
|
||||
|
||||
cur->p += len;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
#endif /* cursor_h */
|
||||
166
damus-c/damus.c
166
damus-c/damus.c
@@ -6,127 +6,13 @@
|
||||
//
|
||||
|
||||
#include "damus.h"
|
||||
#include "cursor.h"
|
||||
#include "bolt11.h"
|
||||
#include "bech32.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef unsigned char u8;
|
||||
|
||||
struct cursor {
|
||||
const u8 *p;
|
||||
const u8 *start;
|
||||
const u8 *end;
|
||||
};
|
||||
|
||||
static inline int is_whitespace(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r';
|
||||
}
|
||||
|
||||
static inline int is_boundary(char c) {
|
||||
return !isalnum(c);
|
||||
}
|
||||
|
||||
static inline int is_invalid_url_ending(char c) {
|
||||
return c == '!' || c == '?' || c == ')' || c == '.' || c == ',' || c == ';';
|
||||
}
|
||||
|
||||
static void make_cursor(struct cursor *c, const u8 *content, size_t len)
|
||||
{
|
||||
c->start = content;
|
||||
c->end = content + len;
|
||||
c->p = content;
|
||||
}
|
||||
|
||||
static int consume_until_boundary(struct cursor *cur) {
|
||||
char c;
|
||||
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
|
||||
if (is_boundary(c))
|
||||
return 1;
|
||||
|
||||
cur->p++;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int consume_until_whitespace(struct cursor *cur, int or_end) {
|
||||
char c;
|
||||
bool consumedAtLeastOne = false;
|
||||
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
|
||||
if (is_whitespace(c))
|
||||
return consumedAtLeastOne;
|
||||
|
||||
cur->p++;
|
||||
consumedAtLeastOne = true;
|
||||
}
|
||||
|
||||
return or_end;
|
||||
}
|
||||
|
||||
static int parse_char(struct cursor *cur, char c) {
|
||||
if (cur->p >= cur->end)
|
||||
return 0;
|
||||
|
||||
if (*cur->p == c) {
|
||||
cur->p++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static inline int peek_char(struct cursor *cur, int ind) {
|
||||
if ((cur->p + ind < cur->start) || (cur->p + ind >= cur->end))
|
||||
return -1;
|
||||
|
||||
return *(cur->p + ind);
|
||||
}
|
||||
|
||||
static int parse_digit(struct cursor *cur, int *digit) {
|
||||
int c;
|
||||
if ((c = peek_char(cur, 0)) == -1)
|
||||
return 0;
|
||||
|
||||
c -= '0';
|
||||
|
||||
if (c >= 0 && c <= 9) {
|
||||
*digit = c;
|
||||
cur->p++;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_str(struct cursor *cur, const char *str) {
|
||||
int i;
|
||||
char c, cs;
|
||||
unsigned long len;
|
||||
|
||||
len = strlen(str);
|
||||
|
||||
if (cur->p + len >= cur->end)
|
||||
return 0;
|
||||
|
||||
for (i = 0; i < len; i++) {
|
||||
c = tolower(cur->p[i]);
|
||||
cs = tolower(str[i]);
|
||||
|
||||
if (c != cs)
|
||||
return 0;
|
||||
}
|
||||
|
||||
cur->p += len;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int parse_mention(struct cursor *cur, struct block *block) {
|
||||
static int parse_mention_index(struct cursor *cur, struct block *block) {
|
||||
int d1, d2, d3, ind;
|
||||
const u8 *start = cur->p;
|
||||
|
||||
@@ -151,8 +37,8 @@ static int parse_mention(struct cursor *cur, struct block *block) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
block->type = BLOCK_MENTION;
|
||||
block->block.mention = ind;
|
||||
block->type = BLOCK_MENTION_INDEX;
|
||||
block->block.mention_index = ind;
|
||||
|
||||
return 1;
|
||||
}
|
||||
@@ -274,6 +160,27 @@ static int parse_invoice(struct cursor *cur, struct block *block) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
static int parse_mention_bech32(struct cursor *cur, struct block *block) {
|
||||
const u8 *start = cur->p;
|
||||
|
||||
if (!parse_str(cur, "nostr:"))
|
||||
return 0;
|
||||
|
||||
block->block.str.start = (const char *)cur->p;
|
||||
|
||||
if (!parse_nostr_bech32(cur, &block->block.mention_bech32.bech32)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
block->block.str.end = (const char *)cur->p;
|
||||
|
||||
block->type = BLOCK_MENTION_BECH32;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int add_text_then_block(struct cursor *cur, struct blocks *blocks, struct block block, const u8 **start, const u8 *pre_mention)
|
||||
{
|
||||
if (!add_text_block(blocks, *start, pre_mention))
|
||||
@@ -303,7 +210,7 @@ int damus_parse_content(struct blocks *blocks, const char *content) {
|
||||
|
||||
pre_mention = cur.p;
|
||||
if (cp == -1 || is_whitespace(cp)) {
|
||||
if (c == '#' && (parse_mention(&cur, &block) || parse_hashtag(&cur, &block))) {
|
||||
if (c == '#' && (parse_mention_index(&cur, &block) || parse_hashtag(&cur, &block))) {
|
||||
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
|
||||
return 0;
|
||||
continue;
|
||||
@@ -315,6 +222,10 @@ int damus_parse_content(struct blocks *blocks, const char *content) {
|
||||
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
|
||||
return 0;
|
||||
continue;
|
||||
} else if (c == 'n' && parse_mention_bech32(&cur, &block)) {
|
||||
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
|
||||
return 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,8 +246,17 @@ void blocks_init(struct blocks *blocks) {
|
||||
}
|
||||
|
||||
void blocks_free(struct blocks *blocks) {
|
||||
if (blocks->blocks) {
|
||||
free(blocks->blocks);
|
||||
blocks->num_blocks = 0;
|
||||
if (!blocks->blocks) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < blocks->num_blocks; ++i) {
|
||||
if (blocks->blocks[i].type == BLOCK_MENTION_BECH32) {
|
||||
free(blocks->blocks[i].block.mention_bech32.bech32.buffer);
|
||||
blocks->blocks[i].block.mention_bech32.bech32.buffer = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
free(blocks->blocks);
|
||||
blocks->num_blocks = 0;
|
||||
}
|
||||
|
||||
@@ -9,45 +9,10 @@
|
||||
#define damus_h
|
||||
|
||||
#include <stdio.h>
|
||||
#include "nostr_bech32.h"
|
||||
#include "block.h"
|
||||
typedef unsigned char u8;
|
||||
|
||||
#define MAX_BLOCKS 1024
|
||||
|
||||
enum block_type {
|
||||
BLOCK_HASHTAG = 1,
|
||||
BLOCK_TEXT = 2,
|
||||
BLOCK_MENTION = 3,
|
||||
BLOCK_URL = 4,
|
||||
BLOCK_INVOICE = 5,
|
||||
};
|
||||
|
||||
typedef struct str_block {
|
||||
const char *start;
|
||||
const char *end;
|
||||
} str_block_t;
|
||||
|
||||
typedef struct invoice_block {
|
||||
struct str_block invstr;
|
||||
union {
|
||||
struct bolt11 *bolt11;
|
||||
};
|
||||
} invoice_block_t;
|
||||
|
||||
typedef struct block {
|
||||
enum block_type type;
|
||||
union {
|
||||
struct str_block str;
|
||||
struct invoice_block invoice;
|
||||
int mention;
|
||||
} block;
|
||||
} block_t;
|
||||
|
||||
typedef struct blocks {
|
||||
int num_blocks;
|
||||
struct block *blocks;
|
||||
} blocks_t;
|
||||
|
||||
void blocks_init(struct blocks *blocks);
|
||||
void blocks_free(struct blocks *blocks);
|
||||
int damus_parse_content(struct blocks *blocks, const char *content);
|
||||
|
||||
#endif /* damus_h */
|
||||
|
||||
295
damus-c/nostr_bech32.c
Normal file
295
damus-c/nostr_bech32.c
Normal file
@@ -0,0 +1,295 @@
|
||||
//
|
||||
// nostr_bech32.c
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-09.
|
||||
//
|
||||
|
||||
#include "nostr_bech32.h"
|
||||
#include <stdlib.h>
|
||||
#include "cursor.h"
|
||||
#include "bech32.h"
|
||||
|
||||
#define MAX_TLVS 16
|
||||
|
||||
#define TLV_SPECIAL 0
|
||||
#define TLV_RELAY 1
|
||||
#define TLV_AUTHOR 2
|
||||
#define TLV_KIND 3
|
||||
#define TLV_KNOWN_TLVS 4
|
||||
|
||||
struct nostr_tlv {
|
||||
u8 type;
|
||||
u8 len;
|
||||
const u8 *value;
|
||||
};
|
||||
|
||||
struct nostr_tlvs {
|
||||
struct nostr_tlv tlvs[MAX_TLVS];
|
||||
int num_tlvs;
|
||||
};
|
||||
|
||||
static int parse_nostr_tlv(struct cursor *cur, struct nostr_tlv *tlv) {
|
||||
// get the tlv tag
|
||||
if (!pull_byte(cur, &tlv->type))
|
||||
return 0;
|
||||
|
||||
// unknown, fail!
|
||||
if (tlv->type >= TLV_KNOWN_TLVS)
|
||||
return 0;
|
||||
|
||||
// get the length
|
||||
if (!pull_byte(cur, &tlv->len))
|
||||
return 0;
|
||||
|
||||
// is the reported length greater then our buffer? if so fail
|
||||
if (cur->p + tlv->len > cur->end)
|
||||
return 0;
|
||||
|
||||
tlv->value = cur->p;
|
||||
cur->p += tlv->len;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int parse_nostr_tlvs(struct cursor *cur, struct nostr_tlvs *tlvs) {
|
||||
int i;
|
||||
tlvs->num_tlvs = 0;
|
||||
|
||||
for (i = 0; i < MAX_TLVS; i++) {
|
||||
if (parse_nostr_tlv(cur, &tlvs->tlvs[i])) {
|
||||
tlvs->num_tlvs++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tlvs->num_tlvs == 0)
|
||||
return 0;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int find_tlv(struct nostr_tlvs *tlvs, u8 type, struct nostr_tlv **tlv) {
|
||||
*tlv = NULL;
|
||||
|
||||
for (int i = 0; i < tlvs->num_tlvs; i++) {
|
||||
if (tlvs->tlvs[i].type == type) {
|
||||
*tlv = &tlvs->tlvs[i];
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_type(const char *prefix, enum nostr_bech32_type *type) {
|
||||
// Parse type
|
||||
if (strcmp(prefix, "note") == 0) {
|
||||
*type = NOSTR_BECH32_NOTE;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "npub") == 0) {
|
||||
*type = NOSTR_BECH32_NPUB;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "nprofile") == 0) {
|
||||
*type = NOSTR_BECH32_NPROFILE;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "nevent") == 0) {
|
||||
*type = NOSTR_BECH32_NEVENT;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "nrelay") == 0) {
|
||||
*type = NOSTR_BECH32_NRELAY;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "naddr") == 0) {
|
||||
*type = NOSTR_BECH32_NADDR;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_note(struct cursor *cur, struct bech32_note *note) {
|
||||
return pull_bytes(cur, 32, ¬e->event_id);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_npub(struct cursor *cur, struct bech32_npub *npub) {
|
||||
return pull_bytes(cur, 32, &npub->pubkey);
|
||||
}
|
||||
|
||||
static int tlvs_to_relays(struct nostr_tlvs *tlvs, struct relays *relays) {
|
||||
struct nostr_tlv *tlv;
|
||||
struct str_block *str;
|
||||
|
||||
relays->num_relays = 0;
|
||||
|
||||
for (int i = 0; i < tlvs->num_tlvs; i++) {
|
||||
tlv = &tlvs->tlvs[i];
|
||||
if (tlv->type != TLV_RELAY)
|
||||
continue;
|
||||
|
||||
if (relays->num_relays + 1 > MAX_RELAYS)
|
||||
break;
|
||||
|
||||
str = &relays->relays[relays->num_relays++];
|
||||
str->start = (const char*)tlv->value;
|
||||
str->end = (const char*)(tlv->value + tlv->len);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *nevent) {
|
||||
struct nostr_tlvs tlvs;
|
||||
struct nostr_tlv *tlv;
|
||||
|
||||
if (!parse_nostr_tlvs(cur, &tlvs))
|
||||
return 0;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
|
||||
return 0;
|
||||
|
||||
if (tlv->len != 32)
|
||||
return 0;
|
||||
|
||||
nevent->event_id = tlv->value;
|
||||
|
||||
if (find_tlv(&tlvs, TLV_AUTHOR, &tlv)) {
|
||||
nevent->pubkey = tlv->value;
|
||||
} else {
|
||||
nevent->pubkey = NULL;
|
||||
}
|
||||
|
||||
return tlvs_to_relays(&tlvs, &nevent->relays);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_naddr(struct cursor *cur, struct bech32_naddr *naddr) {
|
||||
struct nostr_tlvs tlvs;
|
||||
struct nostr_tlv *tlv;
|
||||
|
||||
if (!parse_nostr_tlvs(cur, &tlvs))
|
||||
return 0;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
|
||||
return 0;
|
||||
|
||||
naddr->identifier.start = (const char*)tlv->value;
|
||||
naddr->identifier.end = (const char*)tlv->value + tlv->len;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_AUTHOR, &tlv))
|
||||
return 0;
|
||||
|
||||
naddr->pubkey = tlv->value;
|
||||
|
||||
return tlvs_to_relays(&tlvs, &naddr->relays);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_nprofile(struct cursor *cur, struct bech32_nprofile *nprofile) {
|
||||
struct nostr_tlvs tlvs;
|
||||
struct nostr_tlv *tlv;
|
||||
|
||||
if (!parse_nostr_tlvs(cur, &tlvs))
|
||||
return 0;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
|
||||
return 0;
|
||||
|
||||
if (tlv->len != 32)
|
||||
return 0;
|
||||
|
||||
nprofile->pubkey = tlv->value;
|
||||
|
||||
return tlvs_to_relays(&tlvs, &nprofile->relays);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_nrelay(struct cursor *cur, struct bech32_nrelay *nrelay) {
|
||||
struct nostr_tlvs tlvs;
|
||||
struct nostr_tlv *tlv;
|
||||
|
||||
if (!parse_nostr_tlvs(cur, &tlvs))
|
||||
return 0;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
|
||||
return 0;
|
||||
|
||||
nrelay->relay.start = (const char*)tlv->value;
|
||||
nrelay->relay.end = (const char*)tlv->value + tlv->len;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
int parse_nostr_bech32(struct cursor *cur, struct nostr_bech32 *obj) {
|
||||
const u8 *start, *end;
|
||||
|
||||
start = cur->p;
|
||||
|
||||
if (!consume_until_non_alphanumeric(cur, 1)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
end = cur->p;
|
||||
|
||||
size_t data_len;
|
||||
size_t input_len = end - start;
|
||||
if (input_len < 10 || input_len > 10000) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
obj->buffer = malloc(input_len * 2);
|
||||
if (!obj->buffer)
|
||||
return 0;
|
||||
|
||||
u8 data[input_len];
|
||||
char prefix[input_len];
|
||||
|
||||
if (bech32_decode_len(prefix, data, &data_len, (const char*)start, input_len) == BECH32_ENCODING_NONE) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
obj->buflen = 0;
|
||||
if (!bech32_convert_bits(obj->buffer, &obj->buflen, 8, data, data_len, 5, 0)) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
if (!parse_nostr_bech32_type(prefix, &obj->type)) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
struct cursor bcur;
|
||||
make_cursor(&bcur, obj->buffer, obj->buflen);
|
||||
|
||||
switch (obj->type) {
|
||||
case NOSTR_BECH32_NOTE:
|
||||
if (!parse_nostr_bech32_note(&bcur, &obj->data.note))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NPUB:
|
||||
if (!parse_nostr_bech32_npub(&bcur, &obj->data.npub))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NEVENT:
|
||||
if (!parse_nostr_bech32_nevent(&bcur, &obj->data.nevent))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NADDR:
|
||||
if (!parse_nostr_bech32_naddr(&bcur, &obj->data.naddr))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NPROFILE:
|
||||
if (!parse_nostr_bech32_nprofile(&bcur, &obj->data.nprofile))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NRELAY:
|
||||
if (!parse_nostr_bech32_nrelay(&bcur, &obj->data.nrelay))
|
||||
goto fail;
|
||||
break;
|
||||
}
|
||||
|
||||
return 1;
|
||||
|
||||
fail:
|
||||
free(obj->buffer);
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
78
damus-c/nostr_bech32.h
Normal file
78
damus-c/nostr_bech32.h
Normal file
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// nostr_bech32.h
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-09.
|
||||
//
|
||||
|
||||
#ifndef nostr_bech32_h
|
||||
#define nostr_bech32_h
|
||||
|
||||
#include <stdio.h>
|
||||
#include "str_block.h"
|
||||
#include "cursor.h"
|
||||
typedef unsigned char u8;
|
||||
#define MAX_RELAYS 10
|
||||
|
||||
struct relays {
|
||||
struct str_block relays[MAX_RELAYS];
|
||||
int num_relays;
|
||||
};
|
||||
|
||||
enum nostr_bech32_type {
|
||||
NOSTR_BECH32_NOTE = 1,
|
||||
NOSTR_BECH32_NPUB = 2,
|
||||
NOSTR_BECH32_NPROFILE = 3,
|
||||
NOSTR_BECH32_NEVENT = 4,
|
||||
NOSTR_BECH32_NRELAY = 5,
|
||||
NOSTR_BECH32_NADDR = 6,
|
||||
};
|
||||
|
||||
struct bech32_note {
|
||||
const u8 *event_id;
|
||||
};
|
||||
|
||||
struct bech32_npub {
|
||||
const u8 *pubkey;
|
||||
};
|
||||
|
||||
struct bech32_nevent {
|
||||
struct relays relays;
|
||||
const u8 *event_id;
|
||||
const u8 *pubkey; // optional
|
||||
};
|
||||
|
||||
struct bech32_nprofile {
|
||||
struct relays relays;
|
||||
const u8 *pubkey;
|
||||
};
|
||||
|
||||
struct bech32_naddr {
|
||||
struct relays relays;
|
||||
struct str_block identifier;
|
||||
const u8 *pubkey;
|
||||
};
|
||||
|
||||
struct bech32_nrelay {
|
||||
struct str_block relay;
|
||||
};
|
||||
|
||||
typedef struct nostr_bech32 {
|
||||
enum nostr_bech32_type type;
|
||||
u8 *buffer; // holds strings and tlv stuff
|
||||
size_t buflen;
|
||||
|
||||
union {
|
||||
struct bech32_note note;
|
||||
struct bech32_npub npub;
|
||||
struct bech32_nevent nevent;
|
||||
struct bech32_nprofile nprofile;
|
||||
struct bech32_naddr naddr;
|
||||
struct bech32_nrelay nrelay;
|
||||
} data;
|
||||
} nostr_bech32_t;
|
||||
|
||||
|
||||
int parse_nostr_bech32(struct cursor *cur, struct nostr_bech32 *obj);
|
||||
|
||||
#endif /* nostr_bech32_h */
|
||||
16
damus-c/str_block.h
Normal file
16
damus-c/str_block.h
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// str_block.h
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-09.
|
||||
//
|
||||
|
||||
#ifndef str_block_h
|
||||
#define str_block_h
|
||||
|
||||
typedef struct str_block {
|
||||
const char *start;
|
||||
const char *end;
|
||||
} str_block_t;
|
||||
|
||||
#endif /* str_block_h */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,15 +17,6 @@
|
||||
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "starscream",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/daltoniam/Starscream",
|
||||
"state" : {
|
||||
"revision" : "df8d82047f6654d8e4b655d1b1525c64e1059d21",
|
||||
"version" : "4.0.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "vault",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xF4",
|
||||
"green" : "0xEE",
|
||||
"red" : "0xEE"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x1E",
|
||||
"green" : "0x1C",
|
||||
"red" : "0x1C"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"red" : "0xBE",
|
||||
"green" : "0x5F",
|
||||
"blue" : "0x00"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xED",
|
||||
"green" : "0x26",
|
||||
"red" : "0xBF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x05",
|
||||
"green" : "0xDF",
|
||||
"red" : "0xFA"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
damus/Assets.xcassets/Hashtags/Contents.json
Normal file
6
damus/Assets.xcassets/Hashtags/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
23
damus/Assets.xcassets/Hashtags/bitcoin-hashtag.imageset/Contents.json
vendored
Normal file
23
damus/Assets.xcassets/Hashtags/bitcoin-hashtag.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "bitcoin-hashtag.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-hashtag.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-hashtag.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
43
damus/Assets.xcassets/Hashtags/bitcoin-hashtag.imageset/bitcoin-hashtag.svg
vendored
Normal file
43
damus/Assets.xcassets/Hashtags/bitcoin-hashtag.imageset/bitcoin-hashtag.svg
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="12.843903"
|
||||
height="17"
|
||||
viewBox="0 0 12.843902 16.999999"
|
||||
version="1.1"
|
||||
id="svg2"
|
||||
sodipodi:docname="bitcoin-hashtag.svg"
|
||||
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="namedview2"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="31.12"
|
||||
inkscape:cx="4.2577121"
|
||||
inkscape:cy="7.535347"
|
||||
inkscape:window-width="1526"
|
||||
inkscape:window-height="957"
|
||||
inkscape:window-x="1637"
|
||||
inkscape:window-y="10"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg2" />
|
||||
<g
|
||||
id="surface1"
|
||||
transform="matrix(0.94507527,0,0,0.94507527,-4.5943665,-3.2875042)">
|
||||
<path
|
||||
style="fill:#f59119;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.40637"
|
||||
d="M 18.388175,10.742602 C 18.668352,8.874761 17.240002,7.8694225 15.295251,7.193703 L 15.927019,4.6611305 14.383304,4.2765743 13.768015,6.7432244 C 13.361486,6.644338 12.943967,6.545453 12.531944,6.4520613 L 13.152726,3.9689298 11.609011,3.584375 10.977241,6.1169476 C 10.642129,6.0400371 10.312509,5.9686201 9.988384,5.886215 L 9.993834,5.880715 7.8623408,5.3478364 7.4558114,6.9959316 c 0,0 1.1426793,0.2636953 1.1207046,0.2801765 0.6207823,0.1538223 0.7361485,0.5658464 0.714174,0.8954659 l -0.7196673,2.889659 c 0.043949,0.01099 0.098885,0.02747 0.1648089,0.04944 L 8.5710227,11.072223 7.5601911,15.11555 c -0.076912,0.192277 -0.26919,0.477948 -0.7031873,0.368073 0.010984,0.02198 -1.1261994,-0.280174 -1.1261994,-0.280174 l -0.7636164,1.76346 2.0106754,0.505417 c 0.3735682,0.0934 0.7361485,0.186784 1.0987301,0.280176 l -0.6427568,2.565534 1.5437155,0.384556 0.6317701,-2.538067 c 0.4230107,0.115364 0.8295407,0.219746 1.2305777,0.318632 l -0.63177,2.527079 1.543716,0.384557 0.637263,-2.560042 c 2.631459,0.499922 4.614667,0.296656 5.444208,-2.082094 0.670226,-1.917284 -0.03296,-3.021507 -1.417363,-3.741176 1.010832,-0.236227 1.768957,-0.895465 1.972221,-2.268877 z m -3.526922,4.944286 c -0.477949,1.917283 -3.702721,0.884477 -4.752008,0.620782 l 0.851514,-3.395075 c 1.043795,0.2582 4.394922,0.774604 3.900494,2.774293 z M 15.3392,10.715134 c -0.433999,1.74698 -3.120394,0.857008 -3.993884,0.642757 l 0.769111,-3.081939 c 0.87349,0.219746 3.675252,0.620784 3.224773,2.439182 z m 0,0"
|
||||
id="path2" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
23
damus/Assets.xcassets/Hashtags/coffee-hashtag.imageset/Contents.json
vendored
Normal file
23
damus/Assets.xcassets/Hashtags/coffee-hashtag.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "coffee.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "coffee.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "coffee.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
55
damus/Assets.xcassets/Hashtags/coffee-hashtag.imageset/coffee.svg
vendored
Normal file
55
damus/Assets.xcassets/Hashtags/coffee-hashtag.imageset/coffee.svg
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 13.999999 18"
|
||||
enable-background="new 0 0 1000 1000"
|
||||
xml:space="preserve"
|
||||
id="svg4"
|
||||
sodipodi:docname="coffee.svg"
|
||||
width="14"
|
||||
height="18"
|
||||
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs4" /><sodipodi:namedview
|
||||
id="namedview4"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="10.680141"
|
||||
inkscape:cx="-17.181421"
|
||||
inkscape:cy="4.07298"
|
||||
inkscape:window-width="1368"
|
||||
inkscape:window-height="947"
|
||||
inkscape:window-x="1764"
|
||||
inkscape:window-y="58"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg4" />
|
||||
<metadata
|
||||
id="metadata1"> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
|
||||
<g
|
||||
id="g4"
|
||||
transform="matrix(0.01779387,0,0,0.01779387,-1.8340539,0.04465199)"><g
|
||||
transform="matrix(0.1,0,0,-0.1,0,511)"
|
||||
id="g3"><path
|
||||
d="m 4302.6,4870.6 c 149.5,-177.8 240.5,-319.3 347.6,-545.6 119.2,-254.6 169.7,-448.6 183.9,-699.2 22.2,-462.7 -137.4,-778 -539.5,-1060.9 -474.9,-335.4 -685,-739.6 -687,-1315.5 0,-260.7 38.4,-501.1 115.2,-739.6 50.5,-149.5 56.6,-159.6 60.6,-97 18.2,234.4 56.6,476.9 101,626.4 121.2,422.3 305.1,622.4 885.1,959.8 424.3,248.5 575.9,487 575.9,905.3 -2,501.1 -359.7,1295.3 -798.2,1768.1 -82.8,88.9 -198,202.1 -256.6,250.6 l -105.1,86.9 z"
|
||||
id="path1"
|
||||
style="fill:#be5f00;fill-opacity:1" /><path
|
||||
d="m 5981.8,3577.3 c 272.8,-369.8 309.2,-846.7 90.9,-1192.2 -147.5,-232.4 -373.8,-406.2 -822.4,-638.5 -592,-303.1 -854.7,-683 -854.7,-1232.6 0,-276.8 14.2,-343.5 72.7,-343.5 38.4,0 48.5,16.2 68.7,111.1 34.4,167.7 135.4,349.6 262.7,476.9 147.5,145.5 349.6,244.5 838.6,412.2 503.2,171.8 725.4,280.9 846.7,416.3 210.1,232.4 276.8,535.5 202.1,903.2 -76.8,373.8 -216.2,618.3 -537.5,943.7 -155.7,155.6 -214.3,206.1 -167.8,143.4 z"
|
||||
id="path2"
|
||||
style="fill:#be5f00;fill-opacity:1" /><path
|
||||
d="M 2748.7,592.8 C 2158.7,507.9 1732.3,352.3 1542.4,156.3 1415.1,25 1409,-11.4 1427.2,-445.8 c 34.3,-832.5 181.9,-1729.7 462.7,-2829 153.6,-600.1 309.2,-1113.4 351.6,-1159.9 56.6,-62.6 272.8,-157.6 476.9,-210.1 668.8,-173.8 2172.2,-196 2960.3,-42.4 357.7,68.7 604.2,163.7 731.5,278.9 68.7,60.6 84.9,92.9 107.1,198 64.7,335.4 56.6,319.3 131.3,319.3 107.1,0 438.5,92.9 602.2,167.7 220.3,103.1 363.7,202.1 567.8,398.1 470.8,452.6 759.8,1149.8 761.8,1840.8 0,398.1 -72.7,614.3 -274.8,818.4 -220.3,222.3 -466.8,309.2 -937.6,325.4 l -309.2,12.1 14.2,218.2 14.2,218.2 -56.6,52.5 C 6866.9,314 6274.9,499.9 5696.9,582.7 L 5614,594.8 5533.2,457.4 5450.4,322 5565.6,305.8 c 588,-84.9 868.9,-159.6 978,-256.6 56.6,-50.5 60.6,-62.6 44.5,-113.2 -80.8,-226.3 -1000.2,-371.8 -2329.8,-371.8 -1220.5,0 -2097.5,123.3 -2295.5,321.3 -52.5,54.5 -56.6,66.7 -36.4,111.1 48.5,107.1 341.5,206.1 822.4,276.8 143.5,20.2 301.1,38.4 349.6,38.4 46.5,0 84.9,4 84.9,8.1 0,8.1 -175.8,295 -185.9,305.1 -4.2,2.1 -115.3,-12 -248.7,-32.2 z m 4797.1,-1614.5 c 153.6,-26.3 272.8,-82.8 317.2,-153.6 56.6,-84.9 60.6,-400.1 8.1,-652.7 -111.1,-545.6 -404.1,-996.2 -788.1,-1220.5 -139.4,-80.8 -333.4,-151.5 -355.6,-129.3 -8.1,8.1 18.2,216.2 58.6,462.7 78.8,482.9 159.6,1073 202.1,1450.8 l 24.2,232.4 70.7,12.1 c 115.3,20.3 325.4,20.3 462.8,-1.9 z"
|
||||
id="path3"
|
||||
style="fill:#be5f00;fill-opacity:1" /></g></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
23
damus/Assets.xcassets/Hashtags/nostr-hashtag.imageset/Contents.json
vendored
Normal file
23
damus/Assets.xcassets/Hashtags/nostr-hashtag.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "nostr-hashtag.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "nostr-hashtag.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "nostr-hashtag.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
51
damus/Assets.xcassets/Hashtags/nostr-hashtag.imageset/nostr-hashtag.svg
vendored
Normal file
51
damus/Assets.xcassets/Hashtags/nostr-hashtag.imageset/nostr-hashtag.svg
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.0"
|
||||
width="18pt"
|
||||
height="18.199053pt"
|
||||
viewBox="0 0 18 18.199053"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
id="svg1"
|
||||
sodipodi:docname="nostr-hashtag.svg"
|
||||
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="pt"
|
||||
showgrid="false"
|
||||
inkscape:zoom="5.4017383"
|
||||
inkscape:cx="50.354161"
|
||||
inkscape:cy="-13.514168"
|
||||
inkscape:window-width="1497"
|
||||
inkscape:window-height="866"
|
||||
inkscape:window-x="1747"
|
||||
inkscape:window-y="96"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg1" />
|
||||
<metadata
|
||||
id="metadata1">
|
||||
Created by potrace 1.15, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g
|
||||
transform="matrix(0.00138509,0,0,-0.00138509,0.3,17.927982)"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
id="g1">
|
||||
<path
|
||||
d="m 11315,12756 c -49,-23 -135,-71 -190,-106 -128,-81 -170,-100 -222,-100 -37,0 -43,-3 -43,-22 0,-11 12,-36 26,-55 l 26,-34 -20,-37 c -128,-248 -171,-359 -212,-547 -56,-262 -43,-645 36,-1045 48,-244 104,-451 234,-865 158,-503 280,-942 320,-1145 27,-140 60,-448 60,-559 0,-164 -39,-366 -92,-478 -57,-123 -176,-222 -286,-239 -49,-8 -50,-23 -5,-38 18,-6 34,-14 38,-18 13,-13 -25,-66 -97,-134 -121,-115 -267,-174 -432,-174 -200,0 -327,87 -732,507 -233,240 -541,529 -604,567 -31,18 -32,18 -27,0 3,-11 9,-36 13,-56 8,-49 -5,-49 -65,1 -75,62 -168,118 -250,150 -134,51 -214,63 -481,70 l -245,6 -65,41 c -100,63 -125,68 -295,66 -186,-3 -262,7 -484,64 -279,71 -341,67 -780,-61 -334,-97 -442,-118 -761,-150 -200,-19 -365,-19 -496,1 -58,9 -107,14 -111,11 -3,-4 6,-22 21,-41 l 27,-35 -23,-5 c -13,-3 -61,-8 -106,-11 -113,-9 -157,-26 -219,-87 -50,-49 -54,-51 -140,-64 -60,-10 -108,-25 -153,-48 -304,-157 -331,-166 -471,-166 -48,0 -120,7 -160,15 -40,9 -74,13 -78,10 -3,-3 0,-20 7,-37 6,-18 10,-34 7,-36 -2,-3 -24,0 -47,7 -24,6 -103,16 -176,23 -163,15 -350,1 -521,-40 l -114,-26 51,-23 c 53,-23 76,-50 66,-77 -3,-8 -51,-38 -105,-66 -251,-129 -515,-384 -727,-703 -110,-166 -208,-325 -217,-355 -6,-21 -5,-22 34,-16 36,6 41,4 41,-12 0,-11 -15,-45 -34,-76 -46,-75 -76,-161 -127,-359 -78,-307 -137,-444 -243,-569 -38,-44 -51,-67 -43,-72 7,-4 40,-8 75,-8 37,0 62,-4 62,-11 0,-6 -12,-26 -27,-45 -32,-43 -24,-53 48,-60 108,-11 103,-6 95,-82 -4,-37 -11,-128 -15,-202 -5,-74 -16,-164 -26,-200 -22,-85 -75,-190 -136,-269 -27,-35 -49,-67 -49,-72 0,-4 17,-11 38,-14 20,-3 49,-8 64,-10 64,-10 285,31 408,76 101,37 249,117 348,188 54,38 110,75 126,80 15,6 70,11 123,11 267,0 582,98 766,239 34,26 72,53 83,59 25,13 31,8 58,-46 21,-40 38,-32 57,26 30,93 97,200 141,223 25,14 38,3 98,-86 27,-38 73,-95 104,-125 31,-30 56,-63 56,-75 0,-11 -20,-60 -45,-110 -42,-84 -81,-202 -70,-213 3,-3 27,16 54,41 50,47 81,59 81,34 0,-8 -10,-49 -21,-93 -26,-97 -32,-275 -10,-313 l 14,-25 36,41 c 42,48 118,88 251,131 102,33 185,47 345,57 55,4 159,13 230,21 292,34 285,35 368,-56 66,-72 122,-115 170,-130 20,-6 37,-15 37,-19 0,-12 -22,-35 -75,-77 -27,-21 -151,-133 -275,-248 -424,-394 -462,-415 -864,-486 -65,-12 -145,-29 -177,-39 -191,-60 -348,-213 -554,-541 -103,-165 -162,-241 -292,-383 -176,-190 -332,-333 -778,-711 -497,-422 -750,-737 -895,-1113 -64,-165 -104,-203 -216,-203 -38,0 -94,7 -124,15 -74,20 -244,20 -301,0 -27,-10 -61,-33 -87,-62 -41,-45 -43,-46 -89,-39 -25,3 -67,18 -92,33 C 457,781 369,812 175,813 49,814 0,786 0,713 0,669 31,628 122,557 204,491 254,434 469,153 527,77 549,57 597,33 652,7 662,5 780,6 c 69,0 150,6 180,14 30,7 132,32 225,56 281,72 308,76 511,84 181,6 195,8 247,34 76,37 137,107 231,267 230,392 287,481 394,626 219,293 469,581 756,869 284,284 368,342 641,439 164,59 295,123 410,199 101,68 257,219 295,286 14,24 56,76 93,116 100,106 177,142 437,203 165,40 229,62 390,133 191,85 220,94 460,143 118,24 239,49 267,56 29,7 56,10 59,6 4,-3 15,-50 26,-104 14,-73 21,-159 25,-331 6,-265 -3,-385 -47,-605 -50,-247 -105,-393 -222,-592 -59,-98 -74,-167 -58,-256 23,-126 61,-177 174,-232 95,-47 169,-52 357,-28 125,16 244,22 505,26 667,12 1011,-35 1799,-241 277,-73 308,-86 376,-161 72,-79 113,-170 175,-388 19,-66 42,-130 50,-143 42,-63 155,-112 261,-112 109,0 124,57 54,206 -48,102 -53,149 -17,165 31,15 73,-5 102,-48 12,-18 45,-87 75,-153 57,-132 106,-197 173,-233 35,-18 56,-21 136,-20 73,2 110,-3 159,-19 89,-29 333,-32 403,-5 114,44 140,145 60,231 -23,24 -112,72 -272,143 -71,32 -199,124 -250,179 -71,78 -111,140 -224,343 -132,236 -172,297 -262,391 -58,61 -94,89 -148,115 -113,56 -85,53 -866,85 -223,9 -616,36 -656,45 -11,2 -86,12 -165,20 -179,20 -595,95 -712,130 -145,42 -319,139 -371,208 -32,42 -53,102 -67,187 -22,144 48,316 261,640 125,190 193,317 230,428 31,95 60,282 60,392 0,76 4,102 16,114 20,20 25,18 192,-102 73,-52 168,-114 210,-137 83,-47 132,-82 132,-95 0,-4 -14,-17 -32,-28 l -32,-21 29,-11 c 108,-41 305,-52 707,-39 186,6 345,9 353,5 8,-3 15,-15 15,-27 0,-20 3,-21 54,-15 110,13 266,81 426,187 89,58 99,63 170,69 161,14 229,78 523,495 88,124 143,185 169,185 2,0 2,-27 0,-60 -2,-33 0,-60 5,-60 23,0 140,145 212,263 117,192 165,317 271,712 106,394 111,410 135,410 15,0 22,-10 29,-40 13,-53 12,-52 42,-24 40,36 103,133 154,234 25,50 50,90 55,90 6,0 11,-17 13,-37 2,-21 7,-38 12,-38 19,0 93,101 139,190 29,56 87,206 140,363 50,147 114,318 142,380 132,288 199,526 246,867 25,190 25,756 0,975 -23,195 -53,379 -78,485 -46,194 -152,515 -233,706 -130,306 -218,564 -260,764 -48,228 -48,572 1,770 31,128 82,204 166,246 64,33 118,83 187,174 120,158 184,199 503,321 250,96 347,153 347,204 0,41 -42,57 -174,68 -112,9 -385,70 -439,99 -10,5 -27,29 -37,54 -23,53 -99,137 -159,176 -56,35 -72,36 -40,1 24,-26 23,-26 -13,3 -21,16 -42,41 -47,56 -9,26 -14,28 -60,28 -50,0 -101,16 -101,32 0,4 -42,8 -92,7 -88,0 -98,-3 -183,-43 z m 550,-126 c 10,-11 16,-20 13,-20 -3,0 -13,9 -23,20 -10,11 -16,20 -13,20 3,0 13,-9 23,-20 z"
|
||||
id="path1"
|
||||
style="fill:#cc43c5;fill-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
23
damus/Assets.xcassets/Hashtags/plebchain-hashtag.imageset/Contents.json
vendored
Normal file
23
damus/Assets.xcassets/Hashtags/plebchain-hashtag.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "plebchain.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "plebchain.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "plebchain.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
51
damus/Assets.xcassets/Hashtags/plebchain-hashtag.imageset/plebchain.svg
vendored
Normal file
51
damus/Assets.xcassets/Hashtags/plebchain-hashtag.imageset/plebchain.svg
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 21.315096 18"
|
||||
width="21.315096"
|
||||
height="18"
|
||||
version="1.1"
|
||||
id="svg21"
|
||||
sodipodi:docname="plebchain.svg"
|
||||
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs21" />
|
||||
<sodipodi:namedview
|
||||
id="namedview21"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="19.666667"
|
||||
inkscape:cx="12.762712"
|
||||
inkscape:cy="12.991525"
|
||||
inkscape:window-width="1418"
|
||||
inkscape:window-height="883"
|
||||
inkscape:window-x="1745"
|
||||
inkscape:window-y="10"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg21" />
|
||||
<path
|
||||
d="M 18.625339,11.886754 C 17.668676,9.87076 15.553749,8.748431 12.298294,7.531368 12.258627,7.5164347 11.514296,7.2747022 10.649566,6.9942364 9.6532356,6.6713042 8.5682391,6.7520372 7.6316422,7.220569 L 5.7999816,8.136166 13.616623,17.9338 h 4.316652 l 0.703264,-1.662727 c 0.593598,-1.401862 0.641198,-3.009057 -0.0112,-4.384319 z"
|
||||
id="path5"
|
||||
style="fill:#bf26ed;fill-opacity:1;stroke-width:0.466665" />
|
||||
<path
|
||||
d="M 16.627546,5.1089093 C 16.710146,4.5484447 16.797412,3.95578 16.838479,3.6790475 17.083011,2.0219197 15.937348,0.48005806 14.28022,0.23552546 12.623092,-0.00900704 11.081231,1.1366559 10.836698,2.7937838 c -0.0406,0.2762657 -0.128333,0.8689305 -0.210932,1.4298619 -0.282799,0.058333 -0.513332,0.2851324 -0.558132,0.5870648 -0.0658,0.441465 0.084,1.0145298 0.370999,1.2072625 0.09707,1.4303286 1.118596,2.778524 2.547992,2.989457 1.429395,0.210933 2.796257,-0.785398 3.302589,-2.1265932 0.330399,-0.1021996 0.639331,-0.6071313 0.703731,-1.0490632 0.0434,-0.302399 -0.111533,-0.5856647 -0.365399,-0.7228643 z"
|
||||
id="path10"
|
||||
style="fill:#bf26ed;fill-opacity:1;stroke-width:0.466665" />
|
||||
<path
|
||||
d="m 21.193864,14.872477 c -0.0392,-0.501665 -0.310799,-0.956663 -0.684131,-1.294529 l -1.900727,-1.723394 c 0,0 0.172666,0.346732 0.332732,1.276796 L 15.53415,11.550288 c -0.878731,-0.405999 -1.835861,-0.616465 -2.804191,-0.616465 h -0.807331 c -0.87733,0 -1.681394,-0.553464 -1.9338602,-1.393928 C 9.8926348,9.220696 9.819835,8.832897 9.7904351,8.363432 V 8.362965 C 10.503966,7.7651672 10.944031,6.8285703 10.968765,5.8700401 11.247364,5.6651742 11.372897,5.0865094 11.28843,4.648311 11.230564,4.3482453 10.990698,4.1317127 10.706032,4.0850462 10.599632,3.5283147 10.487633,2.93985 10.435366,2.6654509 10.121301,1.0199896 8.5327726,-0.05940684 6.8873113,0.25419216 5.2418501,0.56779106 4.1624536,2.1567859 4.4760526,3.8022471 4.5283191,4.0766462 4.6407854,4.665111 4.7467184,5.2218423 4.4989192,5.3697751 4.355653,5.6595742 4.4125861,5.9596399 4.4956525,6.3983051 4.8255848,6.8901701 5.159717,6.9779032 5.2950499,7.2952355 5.4798493,7.5892345 5.7015152,7.8510336 L 5.7999816,8.136166 5.2171168,11.699621 3.2925898,12.11822 C 3.041524,12.177953 2.8109914,12.278752 2.6061254,12.411752 1.8342613,12.912484 1.5458622,13.865414 1.7731282,14.725478 1.8309946,14.943877 2.8833245,17.9338 2.8833245,17.9338 H 15.133284 l 0.408332,-1.691661 3.480855,0.605265 c 1.184397,0.211399 2.267993,-0.748064 2.171393,-1.974927 z"
|
||||
id="path16"
|
||||
style="fill:#f59119;fill-opacity:1;stroke-width:0.466665" />
|
||||
<path
|
||||
d="m 12.589026,15.06801 -2.251659,-0.776531 c 0,0 0.96693,-0.1106 0.95853,-0.625798 -4.67e-4,-0.03687 -0.01213,-0.240332 -0.307999,-0.210932 -0.0812,0.0079 -0.734064,0.07327 -1.1927962,0.118999 -0.3028657,0.03033 -0.6015314,0.09007 -0.8927304,0.178733 l -0.1591328,0.04853 -3.5256551,-2.101393 c 0,0 0,0 0.3495322,-0.763464 C 6.1779803,9.600561 5.8004482,8.136166 5.8004482,8.136166 L 1.324663,10.373825 C 0.63539857,10.718224 0.2,11.422889 0.2,12.193819 c 0,0.939397 0.64306455,1.756528 1.5558615,1.977727 l 6.4357789,1.371062 2.5834586,0.955731 c 0.167999,0.0532 0.329932,0.0602 0.517065,0.0033 0.187599,-0.05693 0.246399,-0.09847 0.337398,-0.165199 0.2464,-0.180133 0.706065,-0.536665 1.037864,-0.795664 0.170799,-0.133933 0.125533,-0.402266 -0.0784,-0.472732 z"
|
||||
id="path21"
|
||||
style="fill:#bf26ed;fill-opacity:1;stroke-width:0.466665" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
23
damus/Assets.xcassets/Hashtags/zap-hashtag.imageset/Contents.json
vendored
Normal file
23
damus/Assets.xcassets/Hashtags/zap-hashtag.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "zapathon.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "zapathon.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "zapathon.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
60
damus/Assets.xcassets/Hashtags/zap-hashtag.imageset/zapathon.svg
vendored
Normal file
60
damus/Assets.xcassets/Hashtags/zap-hashtag.imageset/zapathon.svg
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="11.106925"
|
||||
height="18"
|
||||
viewBox="0 0 2.9387073 4.7624999"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
|
||||
sodipodi:docname="zapathon.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="16"
|
||||
inkscape:cx="16.96875"
|
||||
inkscape:cy="9.96875"
|
||||
inkscape:window-width="1406"
|
||||
inkscape:window-height="767"
|
||||
inkscape:window-x="1741"
|
||||
inkscape:window-y="214"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-10.993855,-8.058313)">
|
||||
<g
|
||||
transform="matrix(0.01604881,0,0,-0.01604881,10.573102,13.422443)"
|
||||
id="g10"
|
||||
inkscape:label="Bolt"
|
||||
style="display:inline">
|
||||
<path
|
||||
id="path14"
|
||||
style="fill:#c98f19;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
|
||||
d="m 94.833712,155.13322 -8.129016,18.00903 -55.167069,-3.59001 18.868958,-24.19503 44.825648,2.77303 z m 83.691438,80.98476 -8.89814,-11.93085 -11.755,-16.519 -1.505,-2.114 -43.191,-60.691 L 63.081111,74.14633 79.705774,38.853232 C 121.34411,95.870625 162.2814,153.39008 203.27601,210.87013 Z m -22.57414,11.14115 12.32197,61.15287 -21.22997,22.33399 -17.432,-69.53486 -7.0256,-30.70796 27.52164,-9.9823 5.84396,26.73826"
|
||||
sodipodi:nodetypes="cccccccccccccccccccccc" />
|
||||
<path
|
||||
id="path16"
|
||||
style="fill:#fadf05;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
|
||||
d="m 178.78514,236.43412 -5.679,-0.377 -8.065,-0.533 -5.9,-0.389 -33.394,-2.212 6.162,29.388 6.223,29.667 8.95029,38.92409 -54.426393,-75.90909 -61.5969,-86.55 44.1332,2.921 8.9137,0.589 -6.1723,-29.396 -0.9867,-4.71 -1.5106,-7.209 -11.7273,-55.917704 16.3074,22.9175 37.169603,52.234204 43.189,60.693 1.505,2.113 16.907,23.756 h -0.002"
|
||||
sodipodi:nodetypes="cccccccccccccccccccccc" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
21
damus/Assets.xcassets/bitcoin-logo.imageset/Contents.json
vendored
Normal file
21
damus/Assets.xcassets/bitcoin-logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "bitcoin-logo.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
7
damus/Assets.xcassets/bitcoin-logo.imageset/bitcoin-logo.svg
vendored
Normal file
7
damus/Assets.xcassets/bitcoin-logo.imageset/bitcoin-logo.svg
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20px" height="20px" viewBox="0 0 20 20" version="1.1">
|
||||
<g id="surface1">
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(96.862745%,57.647059%,10.196078%);fill-opacity:1;" d="M 19.699219 12.417969 C 18.363281 17.777344 12.9375 21.035156 7.582031 19.699219 C 2.226562 18.363281 -1.035156 12.9375 0.300781 7.582031 C 1.636719 2.222656 7.0625 -1.035156 12.417969 0.300781 C 17.773438 1.632812 21.035156 7.0625 19.699219 12.417969 Z M 19.699219 12.417969 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 14.410156 8.574219 C 14.609375 7.246094 13.59375 6.53125 12.210938 6.050781 L 12.660156 4.25 L 11.5625 3.976562 L 11.125 5.730469 C 10.835938 5.660156 10.539062 5.589844 10.246094 5.523438 L 10.6875 3.757812 L 9.589844 3.484375 L 9.140625 5.285156 C 8.902344 5.230469 8.667969 5.179688 8.4375 5.121094 L 8.441406 5.117188 L 6.925781 4.738281 L 6.636719 5.910156 C 6.636719 5.910156 7.449219 6.097656 7.433594 6.109375 C 7.875 6.21875 7.957031 6.511719 7.941406 6.746094 L 7.429688 8.800781 C 7.460938 8.808594 7.5 8.820312 7.546875 8.835938 L 7.429688 8.808594 L 6.710938 11.683594 C 6.65625 11.820312 6.519531 12.023438 6.210938 11.945312 C 6.21875 11.960938 5.410156 11.746094 5.410156 11.746094 L 4.867188 13 L 6.296875 13.359375 C 6.5625 13.425781 6.820312 13.492188 7.078125 13.558594 L 6.621094 15.382812 L 7.71875 15.65625 L 8.167969 13.851562 C 8.46875 13.933594 8.757812 14.007812 9.042969 14.078125 L 8.59375 15.875 L 9.691406 16.148438 L 10.144531 14.328125 C 12.015625 14.683594 13.425781 14.539062 14.015625 12.847656 C 14.492188 11.484375 13.992188 10.699219 13.007812 10.1875 C 13.726562 10.019531 14.265625 9.550781 14.410156 8.574219 Z M 11.902344 12.089844 C 11.5625 13.453125 9.269531 12.71875 8.523438 12.53125 L 9.128906 10.117188 C 9.871094 10.300781 12.253906 10.667969 11.902344 12.089844 Z M 12.242188 8.554688 C 11.933594 9.796875 10.023438 9.164062 9.402344 9.011719 L 9.949219 6.820312 C 10.570312 6.976562 12.5625 7.261719 12.242188 8.554688 Z M 12.242188 8.554688 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -8,8 +8,8 @@
|
||||
import SwiftUI
|
||||
|
||||
let RECTANGLE_GRADIENT = LinearGradient(gradient: Gradient(colors: [
|
||||
Color("DamusPurple"),
|
||||
Color("DamusBlue")
|
||||
DamusColors.purple,
|
||||
DamusColors.blue
|
||||
]), startPoint: .leading, endPoint: .trailing)
|
||||
|
||||
struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
|
||||
@@ -52,9 +52,10 @@ struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
|
||||
.accentColor(tag == selection ? textColor() : .gray)
|
||||
}
|
||||
}
|
||||
.background(Color(UIColor.systemBackground))
|
||||
}
|
||||
|
||||
func textColor() -> Color {
|
||||
colorScheme == .light ? Color("DamusBlack") : Color("DamusWhite")
|
||||
colorScheme == .light ? DamusColors.black : DamusColors.white
|
||||
}
|
||||
}
|
||||
|
||||
25
damus/Components/DamusColors.swift
Normal file
25
damus/Components/DamusColors.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// DamusColors.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-03-27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class DamusColors {
|
||||
static let adaptableGrey = Color("DamusAdaptableGrey")
|
||||
static let white = Color("DamusWhite")
|
||||
static let black = Color("DamusBlack")
|
||||
static let brown = Color("DamusBrown")
|
||||
static let yellow = Color("DamusYellow")
|
||||
static let lightGrey = Color("DamusLightGrey")
|
||||
static let mediumGrey = Color("DamusMediumGrey")
|
||||
static let darkGrey = Color("DamusDarkGrey")
|
||||
static let green = Color("DamusGreen")
|
||||
static let purple = Color("DamusPurple")
|
||||
static let deepPurple = Color("DamusDeepPurple")
|
||||
static let blue = Color("DamusBlue")
|
||||
}
|
||||
|
||||
44
damus/Components/IconLabel.swift
Normal file
44
damus/Components/IconLabel.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// IconLabel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-05.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct IconLabel: View {
|
||||
let text: String
|
||||
let img_name: String
|
||||
let img_color: Color
|
||||
|
||||
init(_ text: String, img_name: String, color: Color) {
|
||||
self.text = text
|
||||
self.img_name = img_name
|
||||
self.img_color = color
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: img_name)
|
||||
.foregroundColor(img_color)
|
||||
.frame(width: 20)
|
||||
.padding([.trailing], 20)
|
||||
Text(text)
|
||||
}
|
||||
}}
|
||||
|
||||
struct IconLabel_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Form {
|
||||
Section {
|
||||
IconLabel(NSLocalizedString("Keys", comment: "Settings section for managing keys"), img_name: "key.fill", color: .orange)
|
||||
|
||||
IconLabel(NSLocalizedString("Local Notifications", comment: "Section header for damus local notifications user configuration"), img_name: "bell.fill", color: .blue)
|
||||
|
||||
IconLabel(NSLocalizedString("Appearance", comment: "Section header for text and appearance settings"), img_name: "textformat", color: .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,197 +31,130 @@ struct ShareSheet: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageContextMenuModifier: ViewModifier {
|
||||
let url: URL?
|
||||
let image: UIImage?
|
||||
@Binding var showShareSheet: Bool
|
||||
|
||||
enum ImageShape {
|
||||
case square
|
||||
case landscape
|
||||
case portrait
|
||||
case unknown
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
return content.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.url = url
|
||||
} label: {
|
||||
Label(NSLocalizedString("Copy Image URL", comment: "Context menu option to copy the URL of an image into clipboard."), systemImage: "doc.on.doc")
|
||||
}
|
||||
if let someImage = image {
|
||||
Button {
|
||||
UIPasteboard.general.image = someImage
|
||||
} label: {
|
||||
Label(NSLocalizedString("Copy Image", comment: "Context menu option to copy an image into clipboard."), systemImage: "photo.on.rectangle")
|
||||
}
|
||||
Button {
|
||||
UIImageWriteToSavedPhotosAlbum(someImage, nil, nil, nil)
|
||||
} label: {
|
||||
Label(NSLocalizedString("Save Image", comment: "Context menu option to save an image."), systemImage: "square.and.arrow.down")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
showShareSheet = true
|
||||
} label: {
|
||||
Label(NSLocalizedString("Share", comment: "Button to share an image."), systemImage: "square.and.arrow.up")
|
||||
}
|
||||
static func determine_image_shape(_ size: CGSize) -> ImageShape {
|
||||
guard size.height > 0 else {
|
||||
return .unknown
|
||||
}
|
||||
let imageRatio = size.width / size.height
|
||||
switch imageRatio {
|
||||
case 1.0: return .square
|
||||
case ..<1.0: return .portrait
|
||||
case 1.0...: return .landscape
|
||||
default: return .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ImageContainerView: View {
|
||||
|
||||
let url: URL?
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
private struct ImageHandler: ImageModifier {
|
||||
@Binding var handler: UIImage?
|
||||
|
||||
func modify(_ image: UIImage) -> UIImage {
|
||||
handler = image
|
||||
return image
|
||||
}
|
||||
// Try either calculated imagefill from the real image or from metadata hints in tags
|
||||
func lookup_imgmeta_size_hint(events: EventCache, url: URL?) -> CGSize? {
|
||||
guard let url,
|
||||
let meta = events.lookup_img_metadata(url: url),
|
||||
let img_size = meta.meta.dim?.size else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
KFAnimatedImage(url)
|
||||
.imageContext(.note)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.imageModifier(ImageHandler(handler: $image))
|
||||
.clipped()
|
||||
.modifier(ImageContextMenuModifier(url: url, image: image, showShareSheet: $showShareSheet))
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageView: View {
|
||||
|
||||
let urls: [URL?]
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@State private var selectedIndex = 0
|
||||
@State var showMenu = true
|
||||
|
||||
var navBarView: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text(urls[selectedIndex]?.lastPathComponent ?? "")
|
||||
.bold()
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}, label: {
|
||||
Image(systemName: "xmark")
|
||||
})
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
var tabViewIndicator: some View {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(urls.indices, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(index == selectedIndex ? Color(UIColor.label) : Color.secondary)
|
||||
.frame(width: 7, height: 7)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemBackground)
|
||||
.ignoresSafeArea()
|
||||
|
||||
TabView(selection: $selectedIndex) {
|
||||
ForEach(urls.indices, id: \.self) { index in
|
||||
ZoomableScrollView {
|
||||
ImageContainerView(url: urls[index])
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.top, Theme.safeAreaInsets?.top)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
}
|
||||
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}))
|
||||
.ignoresSafeArea()
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||
.gesture(TapGesture(count: 2).onEnded {
|
||||
// Prevents menu from hiding on double tap
|
||||
})
|
||||
.gesture(TapGesture(count: 1).onEnded {
|
||||
showMenu.toggle()
|
||||
})
|
||||
.overlay(
|
||||
VStack {
|
||||
if showMenu {
|
||||
navBarView
|
||||
Spacer()
|
||||
|
||||
if (urls.count > 1) {
|
||||
tabViewIndicator
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut, value: showMenu)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
)
|
||||
}
|
||||
}
|
||||
return img_size
|
||||
}
|
||||
|
||||
struct ImageCarousel: View {
|
||||
var urls: [URL]
|
||||
|
||||
@State var open_sheet: Bool = false
|
||||
@State var current_url: URL? = nil
|
||||
let evid: String
|
||||
|
||||
let state: DamusState
|
||||
|
||||
@State private var open_sheet: Bool = false
|
||||
@State private var current_url: URL? = nil
|
||||
@State private var image_fill: ImageFill? = nil
|
||||
|
||||
let fillHeight: CGFloat = 350
|
||||
let maxHeight: CGFloat = UIScreen.main.bounds.height * 1.2
|
||||
|
||||
init(state: DamusState, evid: String, urls: [URL]) {
|
||||
_open_sheet = State(initialValue: false)
|
||||
_current_url = State(initialValue: nil)
|
||||
_image_fill = State(initialValue: state.previews.lookup_image_meta(evid))
|
||||
self.urls = urls
|
||||
self.evid = evid
|
||||
self.state = state
|
||||
}
|
||||
|
||||
var filling: Bool {
|
||||
image_fill?.filling == true
|
||||
}
|
||||
|
||||
var height: CGFloat {
|
||||
image_fill?.height ?? fillHeight
|
||||
}
|
||||
|
||||
func Placeholder(url: URL, geo_size: CGSize) -> some View {
|
||||
Group {
|
||||
if let meta = state.events.lookup_img_metadata(url: url),
|
||||
case .processed(let blurhash) = meta.state {
|
||||
Image(uiImage: blurhash)
|
||||
.resizable()
|
||||
.frame(width: geo_size.width * UIScreen.main.scale, height: self.height * UIScreen.main.scale)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if self.image_fill == nil,
|
||||
let meta = state.events.lookup_img_metadata(url: url),
|
||||
let size = meta.meta.dim?.size
|
||||
{
|
||||
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight)
|
||||
self.image_fill = fill
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
ForEach(urls, id: \.absoluteString) { url in
|
||||
Rectangle()
|
||||
.foregroundColor(Color.clear)
|
||||
.overlay {
|
||||
KFAnimatedImage(url)
|
||||
.imageContext(.note)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
GeometryReader { geo in
|
||||
KFAnimatedImage(url)
|
||||
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||
.backgroundDecode(true)
|
||||
.imageContext(.note, disable_animation: state.settings.disable_animation)
|
||||
.image_fade(duration: 0.25)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in
|
||||
state.previews.cache_image_meta(evid: evid, image_fill: fill)
|
||||
// blur hash can be discarded when we have the url
|
||||
// NOTE: this is the wrong place for this... we need to remove
|
||||
// it when the image is loaded in memory. This may happen
|
||||
// earlier than this (by the preloader, etc)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
state.events.lookup_img_metadata(url: url)?.state = .not_needed
|
||||
}
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.tabItem {
|
||||
Text(url.absoluteString)
|
||||
}
|
||||
.id(url.absoluteString)
|
||||
.contextMenu {
|
||||
Button(NSLocalizedString("Copy Image", comment: "Context menu option to copy an image to clipboard.")) {
|
||||
UIPasteboard.general.string = url.absoluteString
|
||||
}
|
||||
}
|
||||
}
|
||||
image_fill = fill
|
||||
}
|
||||
.background {
|
||||
Placeholder(url: url, geo_size: geo.size)
|
||||
}
|
||||
.aspectRatio(contentMode: filling ? .fill : .fit)
|
||||
.tabItem {
|
||||
Text(url.absoluteString)
|
||||
}
|
||||
.id(url.absoluteString)
|
||||
}
|
||||
}
|
||||
}
|
||||
.cornerRadius(10)
|
||||
.fullScreenCover(isPresented: $open_sheet) {
|
||||
ImageView(urls: urls)
|
||||
ImageView(urls: urls, disable_animation: state.settings.disable_animation)
|
||||
}
|
||||
.frame(height: 200)
|
||||
.frame(height: self.height)
|
||||
.onTapGesture {
|
||||
open_sheet = true
|
||||
}
|
||||
@@ -229,8 +162,57 @@ struct ImageCarousel: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageCarousel_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ImageCarousel(urls: [URL(string: "https://jb55.com/red-me.jpg")!,URL(string: "https://jb55.com/red-me.jpg")!])
|
||||
// MARK: - Image Modifier
|
||||
extension KFOptionSetter {
|
||||
/// Sets a block to get image size
|
||||
///
|
||||
/// - Parameter block: The block which is used to read the image object.
|
||||
/// - Returns: `Self` value after read size
|
||||
public func imageFill(for size: CGSize, max: CGFloat, fill: CGFloat, block: @escaping (ImageFill) throws -> Void) -> Self {
|
||||
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
|
||||
let img_size = image.size
|
||||
let geo_size = size
|
||||
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: img_size, maxHeight: max, fillHeight: fill)
|
||||
DispatchQueue.main.async { [block, fill] in
|
||||
try? block(fill)
|
||||
}
|
||||
return image
|
||||
}
|
||||
options.imageModifier = modifier
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public struct ImageFill {
|
||||
let filling: Bool?
|
||||
let height: CGFloat
|
||||
|
||||
static func calculate_image_fill(geo_size: CGSize, img_size: CGSize, maxHeight: CGFloat, fillHeight: CGFloat) -> ImageFill {
|
||||
let shape = ImageShape.determine_image_shape(img_size)
|
||||
|
||||
let xfactor = geo_size.width / img_size.width
|
||||
let scaled = img_size.height * xfactor
|
||||
|
||||
//print("calc_img_fill \(img_size.width)x\(img_size.height) xfactor:\(xfactor) scaled:\(scaled)")
|
||||
|
||||
// calculate scaled image height
|
||||
// set scale factor and constrain images to minimum 150
|
||||
// and animations to scaled factor for dynamic size adjustment
|
||||
switch shape {
|
||||
case .portrait, .landscape:
|
||||
let filling = scaled > maxHeight
|
||||
let height = filling ? fillHeight : scaled
|
||||
return ImageFill(filling: filling, height: height)
|
||||
case .square, .unknown:
|
||||
return ImageFill(filling: nil, height: scaled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageCarousel_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ImageCarousel(state: test_damus_state(), evid: "evid", urls: [URL(string: "https://jb55.com/red-me.jpg")!,URL(string: "https://jb55.com/red-me.jpg")!])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ struct InvoiceView: View {
|
||||
let invoice: Invoice
|
||||
@State var showing_select_wallet: Bool = false
|
||||
@State var copied = false
|
||||
let settings: UserSettingsStore
|
||||
|
||||
var CopyButton: some View {
|
||||
Button {
|
||||
@@ -29,17 +30,17 @@ struct InvoiceView: View {
|
||||
.foregroundColor(.gray)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundColor(Color("DamusGreen"))
|
||||
.foregroundColor(DamusColors.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var PayButton: some View {
|
||||
Button {
|
||||
if should_show_wallet_selector(our_pubkey) {
|
||||
if settings.show_wallet_selector {
|
||||
showing_select_wallet = true
|
||||
} else {
|
||||
open_with_wallet(wallet: get_default_wallet(our_pubkey).model, invoice: invoice.string)
|
||||
open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string)
|
||||
}
|
||||
} label: {
|
||||
RoundedRectangle(cornerRadius: 20, style: .circular)
|
||||
@@ -80,7 +81,7 @@ struct InvoiceView: View {
|
||||
.padding(30)
|
||||
}
|
||||
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
|
||||
SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: our_pubkey, invoice: invoice.string)
|
||||
SelectWalletView(default_wallet: settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: our_pubkey, invoice: invoice.string)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +112,8 @@ let test_invoice = Invoice(description: .description("this is a description"), a
|
||||
|
||||
struct InvoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
InvoiceView(our_pubkey: "", invoice: test_invoice)
|
||||
InvoiceView(our_pubkey: "", invoice: test_invoice, settings: test_damus_state().settings)
|
||||
.frame(width: 300, height: 200)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
struct InvoicesView: View {
|
||||
let our_pubkey: String
|
||||
var invoices: [Invoice]
|
||||
let settings: UserSettingsStore
|
||||
|
||||
@State var open_sheet: Bool = false
|
||||
@State var current_invoice: Invoice? = nil
|
||||
@@ -17,7 +18,7 @@ struct InvoicesView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
ForEach(invoices, id: \.string) { invoice in
|
||||
InvoiceView(our_pubkey: our_pubkey, invoice: invoice)
|
||||
InvoiceView(our_pubkey: our_pubkey, invoice: invoice, settings: settings)
|
||||
.tabItem {
|
||||
Text(invoice.string)
|
||||
}
|
||||
@@ -31,7 +32,7 @@ struct InvoicesView: View {
|
||||
|
||||
struct InvoicesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
InvoicesView(our_pubkey: "", invoices: [Invoice.init(description: .description("description"), amount: .specific(10000), string: "invstr", expiry: 100000, payment_hash: Data(), created_at: 1000000)])
|
||||
InvoicesView(our_pubkey: "", invoices: [Invoice.init(description: .description("description"), amount: .specific(10000), string: "invstr", expiry: 100000, payment_hash: Data(), created_at: 1000000)], settings: test_damus_state().settings)
|
||||
.frame(width: 300)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,19 +24,33 @@ struct NIP05Badge: View {
|
||||
self.clickable = clickable
|
||||
}
|
||||
|
||||
var nip05_color: Color {
|
||||
return get_nip05_color(pubkey: pubkey, contacts: contacts)
|
||||
var nip05_color: Bool {
|
||||
return use_nip05_color(pubkey: pubkey, contacts: contacts)
|
||||
}
|
||||
|
||||
var Seal: some View {
|
||||
Group {
|
||||
if nip05_color {
|
||||
LINEAR_GRADIENT
|
||||
.mask(Image(systemName: "checkmark.seal.fill")
|
||||
.resizable()
|
||||
).frame(width: 14, height: 14)
|
||||
} else if show_domain {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.footnote)
|
||||
.nip05_colorized(gradient: nip05_color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.footnote)
|
||||
.foregroundColor(nip05_color)
|
||||
Seal
|
||||
|
||||
if show_domain {
|
||||
if clickable {
|
||||
Text(nip05.host)
|
||||
.foregroundColor(nip05_color)
|
||||
.nip05_colorized(gradient: nip05_color)
|
||||
.onTapGesture {
|
||||
if let nip5url = nip05.siteUrl {
|
||||
openURL(nip5url)
|
||||
@@ -44,7 +58,7 @@ struct NIP05Badge: View {
|
||||
}
|
||||
} else {
|
||||
Text(nip05.host)
|
||||
.foregroundColor(nip05_color)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,8 +66,19 @@ struct NIP05Badge: View {
|
||||
}
|
||||
}
|
||||
|
||||
func get_nip05_color(pubkey: String, contacts: Contacts) -> Color {
|
||||
return contacts.is_friend_or_self(pubkey) ? .accentColor : .gray
|
||||
extension View {
|
||||
func nip05_colorized(gradient: Bool) -> some View {
|
||||
if gradient {
|
||||
return AnyView(self.foregroundStyle(LINEAR_GRADIENT))
|
||||
} else {
|
||||
return AnyView(self.foregroundColor(.gray))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func use_nip05_color(pubkey: String, contacts: Contacts) -> Bool {
|
||||
return contacts.is_friend_or_self(pubkey) ? true : false
|
||||
}
|
||||
|
||||
struct NIP05Badge_Previews: PreviewProvider {
|
||||
|
||||
@@ -15,12 +15,10 @@ struct Reposted: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .center) {
|
||||
Image(systemName: "arrow.2.squarepath")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.gray)
|
||||
ProfileName(pubkey: pubkey, profile: profile, damus: damus, show_friend_confirmed: true, show_nip5_domain: false)
|
||||
.foregroundColor(Color.gray)
|
||||
Text("Reposted", comment: "Text indicating that the post was reposted (i.e. re-shared).")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.gray)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,18 @@ struct SelectableText: View {
|
||||
@State private var selectedTextHeight: CGFloat = .zero
|
||||
@State private var selectedTextWidth: CGFloat = .zero
|
||||
|
||||
let size: EventViewKind
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
TextViewRepresentable(
|
||||
attributedString: attributedString,
|
||||
textColor: UIColor.label,
|
||||
font: UIFont.preferredFont(forTextStyle: .title2),
|
||||
font: eventviewsize_to_uifont(size),
|
||||
fixedWidth: selectedTextWidth,
|
||||
height: $selectedTextHeight
|
||||
)
|
||||
.padding([.leading, .trailing], -1.0)
|
||||
.onAppear {
|
||||
self.selectedTextWidth = geo.size.width
|
||||
}
|
||||
@@ -49,8 +52,11 @@ struct SelectableText: View {
|
||||
view.isEditable = false
|
||||
view.dataDetectorTypes = .all
|
||||
view.isSelectable = true
|
||||
view.backgroundColor = .clear
|
||||
view.textContainer.lineFragmentPadding = 0
|
||||
view.textContainerInset = .zero
|
||||
view.textContainerInset.left = 1.0
|
||||
view.textContainerInset.right = 1.0
|
||||
return view
|
||||
}
|
||||
|
||||
|
||||
22
damus/Components/ThiccDivider.swift
Normal file
22
damus/Components/ThiccDivider.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// ThiccDivider.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-03.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ThiccDivider: View {
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.frame(height: 4)
|
||||
.foregroundColor(DamusColors.adaptableGrey)
|
||||
}
|
||||
}
|
||||
|
||||
struct ThiccDivider_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ThiccDivider()
|
||||
}
|
||||
}
|
||||
@@ -8,138 +8,152 @@
|
||||
import SwiftUI
|
||||
import NaturalLanguage
|
||||
|
||||
|
||||
struct Translated: Equatable {
|
||||
let artifacts: NoteArtifacts
|
||||
let language: String
|
||||
}
|
||||
|
||||
enum TranslateStatus: Equatable {
|
||||
case havent_tried
|
||||
case translating
|
||||
case translated(Translated)
|
||||
case not_needed
|
||||
}
|
||||
|
||||
struct TranslateView: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
let size: EventViewKind
|
||||
|
||||
@State var checkingTranslationStatus: Bool = false
|
||||
@State var currentLanguage: String = "en"
|
||||
@State var noteLanguage: String? = nil
|
||||
@State var translated_note: String? = nil
|
||||
@State var show_translated_note: Bool = false
|
||||
@State var translated_artifacts: NoteArtifacts? = nil
|
||||
@ObservedObject var translations_model: TranslationModel
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) {
|
||||
self.damus_state = damus_state
|
||||
self.event = event
|
||||
self.size = size
|
||||
self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
|
||||
}
|
||||
|
||||
var TranslateButton: some View {
|
||||
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
|
||||
show_translated_note = true
|
||||
translate()
|
||||
}
|
||||
.translate_button_style()
|
||||
}
|
||||
|
||||
func Translated(lang: String, artifacts: NoteArtifacts) -> some View {
|
||||
return Group {
|
||||
Button(NSLocalizedString("Translated from \(lang)", comment: "Button to indicate that the note has been translated from a different language.")) {
|
||||
show_translated_note = false
|
||||
}
|
||||
.translate_button_style()
|
||||
func TranslatedView(lang: String?, artifacts: NoteArtifacts) -> some View {
|
||||
return VStack(alignment: .leading) {
|
||||
Text(String(format: NSLocalizedString("Translated from %@", comment: "Button to indicate that the note has been translated from a different language."), lang ?? "ja"))
|
||||
.foregroundColor(.gray)
|
||||
.font(.footnote)
|
||||
.padding([.top, .bottom], 10)
|
||||
|
||||
SelectableText(attributedString: artifacts.content)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckingStatus(lang: String) -> some View {
|
||||
return Button(NSLocalizedString("Translating from \(lang)...", comment: "Button to indicate that the note is in the process of being translated from a different language.")) {
|
||||
show_translated_note = false
|
||||
}
|
||||
.translate_button_style()
|
||||
}
|
||||
|
||||
func MainContent(note_lang: String) -> some View {
|
||||
return Group {
|
||||
let languageName = Locale.current.localizedString(forLanguageCode: note_lang)
|
||||
if let lang = languageName, show_translated_note {
|
||||
if checkingTranslationStatus {
|
||||
CheckingStatus(lang: lang)
|
||||
} else if let artifacts = translated_artifacts {
|
||||
Translated(lang: lang, artifacts: artifacts)
|
||||
}
|
||||
if self.size == .selected {
|
||||
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
|
||||
} else {
|
||||
TranslateButton
|
||||
artifacts.content.text
|
||||
.font(eventviewsize_to_font(self.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func translate() {
|
||||
Task {
|
||||
guard let note_language = translations_model.note_language else {
|
||||
return
|
||||
}
|
||||
let res = await translate_note(profiles: damus_state.profiles, privkey: damus_state.keypair.privkey, event: event, settings: damus_state.settings, note_lang: note_language)
|
||||
DispatchQueue.main.async {
|
||||
self.translations_model.state = res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attempt_translation() {
|
||||
guard should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: self.translations_model.note_language), damus_state.settings.auto_translate else {
|
||||
return
|
||||
}
|
||||
|
||||
translate()
|
||||
}
|
||||
|
||||
func should_transl(_ note_lang: String) -> Bool {
|
||||
should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: note_lang)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let note_lang = noteLanguage, noteLanguage != currentLanguage {
|
||||
MainContent(note_lang: note_lang)
|
||||
} else {
|
||||
switch self.translations_model.state {
|
||||
case .havent_tried:
|
||||
if damus_state.settings.auto_translate {
|
||||
Text("")
|
||||
} else if let note_lang = translations_model.note_language, should_transl(note_lang) {
|
||||
TranslateButton
|
||||
} else {
|
||||
Text("")
|
||||
}
|
||||
case .translating:
|
||||
Text("")
|
||||
case .translated(let translated):
|
||||
let languageName = Locale.current.localizedString(forLanguageCode: translated.language)
|
||||
TranslatedView(lang: languageName, artifacts: translated.artifacts)
|
||||
case .not_needed:
|
||||
Text("")
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard noteLanguage == nil && !checkingTranslationStatus && damus_state.settings.can_translate(damus_state.pubkey) else {
|
||||
return
|
||||
}
|
||||
|
||||
checkingTranslationStatus = true
|
||||
|
||||
if #available(iOS 16, *) {
|
||||
currentLanguage = Locale.current.language.languageCode?.identifier ?? "en"
|
||||
} else {
|
||||
currentLanguage = Locale.current.languageCode ?? "en"
|
||||
}
|
||||
|
||||
// Rely on Apple's NLLanguageRecognizer to tell us which language it thinks the note is in
|
||||
// and filter on only the text portions of the content as URLs and hashtags confuse the language recognizer.
|
||||
let originalBlocks = event.blocks(damus_state.keypair.privkey)
|
||||
let originalOnlyText = originalBlocks.compactMap { $0.is_text }.joined(separator: " ")
|
||||
|
||||
// Only accept language recognition hypothesis if there's at least a 50% probability that it's accurate.
|
||||
let languageRecognizer = NLLanguageRecognizer()
|
||||
languageRecognizer.processString(originalOnlyText)
|
||||
noteLanguage = languageRecognizer.languageHypotheses(withMaximum: 1).first(where: { $0.value >= 0.5 })?.key.rawValue ?? currentLanguage
|
||||
|
||||
if let lang = noteLanguage, noteLanguage != currentLanguage {
|
||||
// If the detected dominant language is a variant, remove the variant component and just take the language part as translation services typically only supports the variant-less language.
|
||||
if #available(iOS 16, *) {
|
||||
noteLanguage = Locale.LanguageCode(stringLiteral: lang).identifier(.alpha2)
|
||||
} else {
|
||||
noteLanguage = NSLocale(localeIdentifier: lang).languageCode
|
||||
}
|
||||
}
|
||||
|
||||
guard let note_lang = noteLanguage else {
|
||||
noteLanguage = currentLanguage
|
||||
translated_note = nil
|
||||
checkingTranslationStatus = false
|
||||
return
|
||||
}
|
||||
|
||||
if note_lang != currentLanguage {
|
||||
do {
|
||||
// If the note language is different from our language, send a translation request.
|
||||
let translator = Translator(damus_state.settings)
|
||||
let originalContent = event.get_content(damus_state.keypair.privkey)
|
||||
translated_note = try await translator.translate(originalContent, from: note_lang, to: currentLanguage)
|
||||
|
||||
if originalContent == translated_note {
|
||||
// If the translation is the same as the original, don't bother showing it.
|
||||
noteLanguage = currentLanguage
|
||||
translated_note = nil
|
||||
}
|
||||
} catch {
|
||||
// If for whatever reason we're not able to figure out the language of the note, or translate the note, fail gracefully and do not retry. It's not the end of the world. Don't want to take down someone's translation server with an accidental denial of service attack.
|
||||
noteLanguage = currentLanguage
|
||||
translated_note = nil
|
||||
}
|
||||
}
|
||||
|
||||
if let translated = translated_note {
|
||||
// Render translated note.
|
||||
let translatedBlocks = event.get_blocks(content: translated)
|
||||
translated_artifacts = render_blocks(blocks: translatedBlocks, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey)
|
||||
}
|
||||
|
||||
checkingTranslationStatus = false
|
||||
|
||||
attempt_translation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func translate_button_style() -> some View {
|
||||
return self
|
||||
.font(.footnote)
|
||||
.contentShape(Rectangle())
|
||||
.padding([.top, .bottom], 10)
|
||||
}
|
||||
}
|
||||
|
||||
struct TranslateView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let ds = test_damus_state()
|
||||
TranslateView(damus_state: ds, event: test_event)
|
||||
TranslateView(damus_state: ds, event: test_event, size: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
func translate_note(profiles: Profiles, privkey: String?, event: NostrEvent, settings: UserSettingsStore, note_lang: String) async -> TranslateStatus {
|
||||
|
||||
// If the note language is different from our preferred languages, send a translation request.
|
||||
let translator = Translator(settings)
|
||||
let originalContent = event.get_content(privkey)
|
||||
let translated_note = try? await translator.translate(originalContent, from: note_lang, to: current_language())
|
||||
|
||||
guard let translated_note else {
|
||||
// if its the same, give up and don't retry
|
||||
return .not_needed
|
||||
}
|
||||
|
||||
guard originalContent != translated_note else {
|
||||
// if its the same, give up and don't retry
|
||||
return .not_needed
|
||||
}
|
||||
|
||||
// Render translated note
|
||||
let translated_blocks = event.get_blocks(content: translated_note)
|
||||
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles, privkey: privkey)
|
||||
|
||||
// and cache it
|
||||
return .translated(Translated(artifacts: artifacts, language: note_lang))
|
||||
}
|
||||
|
||||
func current_language() -> String {
|
||||
if #available(iOS 16, *) {
|
||||
return Locale.current.language.languageCode?.identifier ?? "en"
|
||||
} else {
|
||||
return Locale.current.languageCode ?? "en"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
damus/Components/TruncatedText.swift
Normal file
53
damus/Components/TruncatedText.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// TruncatedText.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-06.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TruncatedText: View {
|
||||
let text: CompatibleText
|
||||
let maxChars: Int = 280
|
||||
|
||||
var body: some View {
|
||||
let truncatedAttributedString: AttributedString? = getTruncatedString()
|
||||
|
||||
if let truncatedAttributedString {
|
||||
Text(truncatedAttributedString)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
text.text
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if truncatedAttributedString != nil {
|
||||
Spacer()
|
||||
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
func getTruncatedString() -> AttributedString? {
|
||||
let nsAttributedString = NSAttributedString(text.attributed)
|
||||
if nsAttributedString.length < maxChars { return nil }
|
||||
|
||||
let range = NSRange(location: 0, length: maxChars)
|
||||
let truncatedAttributedString = nsAttributedString.attributedSubstring(from: range)
|
||||
|
||||
return AttributedString(truncatedAttributedString) + "..."
|
||||
}
|
||||
}
|
||||
|
||||
struct TruncatedText_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 100) {
|
||||
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"))
|
||||
.frame(width: 200, height: 200)
|
||||
|
||||
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"))
|
||||
.frame(width: 200, height: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,31 +7,51 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct UserViewRow: View {
|
||||
let damus_state: DamusState
|
||||
let pubkey: String
|
||||
|
||||
@State var navigating: Bool = false
|
||||
|
||||
var body: some View {
|
||||
let dest = ProfileView(damus_state: damus_state, pubkey: pubkey)
|
||||
|
||||
UserView(damus_state: damus_state, pubkey: pubkey)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
NavigationLink(destination: dest, isActive: $navigating) {
|
||||
EmptyView()
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
navigating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UserView: View {
|
||||
let damus_state: DamusState
|
||||
let pubkey: String
|
||||
|
||||
var body: some View {
|
||||
let pmodel = ProfileModel(pubkey: pubkey, damus: damus_state)
|
||||
let followers = FollowersModel(damus_state: damus_state, target: pubkey)
|
||||
let pv = ProfileView(damus_state: damus_state, profile: pmodel, followers: followers)
|
||||
|
||||
NavigationLink(destination: pv) {
|
||||
ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
let profile = damus_state.profiles.lookup(id: pubkey)
|
||||
ProfileName(pubkey: pubkey, profile: profile, damus: damus_state, show_friend_confirmed: false, show_nip5_domain: false)
|
||||
if let about = profile?.about {
|
||||
Text(about)
|
||||
.lineLimit(3)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
VStack {
|
||||
HStack {
|
||||
ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
|
||||
Spacer()
|
||||
VStack(alignment: .leading) {
|
||||
let profile = damus_state.profiles.lookup(id: pubkey)
|
||||
ProfileName(pubkey: pubkey, profile: profile, damus: damus_state, show_friend_confirmed: false, show_nip5_domain: false)
|
||||
if let about = profile?.about {
|
||||
Text(about)
|
||||
.lineLimit(3)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ struct WebsiteLink: View {
|
||||
}, label: {
|
||||
Text(link_text)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.accentColor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,22 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum ZappingEventType {
|
||||
case failed(ZappingError)
|
||||
case got_zap_invoice(String)
|
||||
}
|
||||
|
||||
enum ZappingError {
|
||||
case fetching_invoice
|
||||
case bad_lnurl
|
||||
}
|
||||
|
||||
struct ZappingEvent {
|
||||
let is_custom: Bool
|
||||
let type: ZappingEventType
|
||||
let event: NostrEvent
|
||||
}
|
||||
|
||||
struct ZapButton: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
@@ -19,61 +35,8 @@ struct ZapButton: View {
|
||||
@State var slider_value: Double = 0.0
|
||||
@State var slider_visible: Bool = false
|
||||
@State var showing_select_wallet: Bool = false
|
||||
|
||||
func send_zap() {
|
||||
guard let privkey = damus_state.keypair.privkey else {
|
||||
return
|
||||
}
|
||||
|
||||
// Only take the first 10 because reasons
|
||||
let relays = Array(damus_state.pool.descriptors.prefix(10))
|
||||
let target = ZapTarget.note(id: event.id, author: event.pubkey)
|
||||
// TODO: gather comment?
|
||||
let content = ""
|
||||
let zapreq = make_zap_request_event(pubkey: damus_state.pubkey, privkey: privkey, content: content, relays: relays, target: target)
|
||||
|
||||
zapping = true
|
||||
|
||||
Task {
|
||||
var mpayreq = damus_state.lnurls.lookup(target.pubkey)
|
||||
if mpayreq == nil {
|
||||
mpayreq = await fetch_static_payreq(lnurl)
|
||||
}
|
||||
|
||||
guard let payreq = mpayreq else {
|
||||
// TODO: show error
|
||||
DispatchQueue.main.async {
|
||||
zapping = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
damus_state.lnurls.endpoints[target.pubkey] = payreq
|
||||
}
|
||||
|
||||
let zap_amount = get_default_zap_amount(pubkey: damus_state.pubkey) ?? 1000
|
||||
guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, sats: zap_amount) else {
|
||||
DispatchQueue.main.async {
|
||||
zapping = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
zapping = false
|
||||
|
||||
if should_show_wallet_selector(damus_state.pubkey) {
|
||||
self.invoice = inv
|
||||
self.showing_select_wallet = true
|
||||
} else {
|
||||
open_with_wallet(wallet: get_default_wallet(damus_state.pubkey).model, invoice: inv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//damus_state.pool.send(.event(zapreq))
|
||||
}
|
||||
@State var showing_zap_customizer: Bool = false
|
||||
@State var is_charging: Bool = false
|
||||
|
||||
var zap_img: String {
|
||||
if bar.zapped {
|
||||
@@ -84,7 +47,7 @@ struct ZapButton: View {
|
||||
return "bolt"
|
||||
}
|
||||
|
||||
return "bolt.horizontal.fill"
|
||||
return "bolt.fill"
|
||||
}
|
||||
|
||||
var zap_color: Color? {
|
||||
@@ -92,6 +55,10 @@ struct ZapButton: View {
|
||||
return Color.orange
|
||||
}
|
||||
|
||||
if is_charging {
|
||||
return Color.yellow
|
||||
}
|
||||
|
||||
if !zapping {
|
||||
return nil
|
||||
}
|
||||
@@ -101,13 +68,27 @@ struct ZapButton: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
EventActionButton(img: zap_img, col: zap_color) {
|
||||
if bar.zapped {
|
||||
//notify(.delete, bar.our_tip)
|
||||
} else if !zapping {
|
||||
send_zap()
|
||||
Button(action: {
|
||||
}, label: {
|
||||
Image(systemName: zap_img)
|
||||
.foregroundColor(zap_color == nil ? Color.gray : zap_color!)
|
||||
.font(.footnote.weight(.medium))
|
||||
})
|
||||
.simultaneousGesture(LongPressGesture().onEnded {_ in
|
||||
guard !zapping else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
self.showing_zap_customizer = true
|
||||
})
|
||||
.highPriorityGesture(TapGesture().onEnded {_ in
|
||||
guard !zapping else {
|
||||
return
|
||||
}
|
||||
|
||||
send_zap(damus_state: damus_state, event: event, lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type)
|
||||
self.zapping = true
|
||||
})
|
||||
.accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button"))
|
||||
|
||||
if bar.zap_total > 0 {
|
||||
@@ -116,8 +97,37 @@ struct ZapButton: View {
|
||||
.foregroundColor(bar.zapped ? Color.orange : Color.gray)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showing_zap_customizer) {
|
||||
CustomizeZapView(state: damus_state, event: event, lnurl: lnurl)
|
||||
}
|
||||
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
|
||||
SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice)
|
||||
SelectWalletView(default_wallet: damus_state.settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice)
|
||||
}
|
||||
.onReceive(handle_notify(.zapping)) { notif in
|
||||
let zap_ev = notif.object as! ZappingEvent
|
||||
|
||||
guard zap_ev.event.id == self.event.id else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !zap_ev.is_custom else {
|
||||
return
|
||||
}
|
||||
|
||||
switch zap_ev.type {
|
||||
case .failed:
|
||||
break
|
||||
case .got_zap_invoice(let inv):
|
||||
if damus_state.settings.show_wallet_selector {
|
||||
self.invoice = inv
|
||||
self.showing_select_wallet = true
|
||||
} else {
|
||||
let wallet = damus_state.settings.default_wallet.model
|
||||
open_with_wallet(wallet: wallet, invoice: inv)
|
||||
}
|
||||
}
|
||||
|
||||
self.zapping = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,8 +135,61 @@ struct ZapButton: View {
|
||||
|
||||
struct ZapButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let bar = ActionBarModel(likes: 0, boosts: 0, zaps: 10, zap_total: 15623414, our_like: nil, our_boost: nil, our_zap: nil)
|
||||
let bar = ActionBarModel(likes: 0, boosts: 0, zaps: 10, zap_total: 15623414, replies: 2, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
|
||||
ZapButton(damus_state: test_damus_state(), event: test_event, lnurl: "lnurl", bar: bar)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_custom: Bool, comment: String?, amount_sats: Int?, zap_type: ZapType) {
|
||||
guard let keypair = damus_state.keypair.to_full() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Only take the first 10 because reasons
|
||||
let relays = Array(damus_state.pool.descriptors.prefix(10))
|
||||
let target = ZapTarget.note(id: event.id, author: event.pubkey)
|
||||
let content = comment ?? ""
|
||||
|
||||
let zapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type)
|
||||
|
||||
Task {
|
||||
var mpayreq = damus_state.lnurls.lookup(target.pubkey)
|
||||
if mpayreq == nil {
|
||||
mpayreq = await fetch_static_payreq(lnurl)
|
||||
}
|
||||
|
||||
guard let payreq = mpayreq else {
|
||||
// TODO: show error
|
||||
DispatchQueue.main.async {
|
||||
let typ = ZappingEventType.failed(.bad_lnurl)
|
||||
let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event)
|
||||
notify(.zapping, ev)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
damus_state.lnurls.endpoints[target.pubkey] = payreq
|
||||
}
|
||||
|
||||
let zap_amount = amount_sats ?? damus_state.settings.default_zap_amount
|
||||
|
||||
guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, sats: zap_amount, zap_type: zap_type, comment: comment) else {
|
||||
DispatchQueue.main.async {
|
||||
let typ = ZappingEventType.failed(.fetching_invoice)
|
||||
let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event)
|
||||
notify(.zapping, ev)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let ev = ZappingEvent(is_custom: is_custom, type: .got_zap_invoice(inv), event: event)
|
||||
notify(.zapping, ev)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,35 +6,23 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Starscream
|
||||
|
||||
var BOOTSTRAP_RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://eden.nostr.land",
|
||||
"wss://relay.snort.social",
|
||||
"wss://offchain.pub",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.current.fyi",
|
||||
"wss://brb.io",
|
||||
]
|
||||
|
||||
struct TimestampedProfile {
|
||||
let profile: Profile
|
||||
let timestamp: Int64
|
||||
let event: NostrEvent
|
||||
}
|
||||
|
||||
enum Sheets: Identifiable {
|
||||
case post
|
||||
case post(PostAction)
|
||||
case report(ReportTarget)
|
||||
case reply(NostrEvent)
|
||||
case event(NostrEvent)
|
||||
case filter
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .report: return "report"
|
||||
case .post: return "post"
|
||||
case .reply(let ev): return "reply-" + ev.id
|
||||
case .post(let action): return "post-" + (action.ev?.id ?? "")
|
||||
case .event(let ev): return "event-" + ev.id
|
||||
case .filter: return "filter"
|
||||
}
|
||||
@@ -74,22 +62,21 @@ struct ContentView: View {
|
||||
@State var status: String = "Not connected"
|
||||
@State var active_sheet: Sheets? = nil
|
||||
@State var damus_state: DamusState? = nil
|
||||
@State var selected_timeline: Timeline? = .home
|
||||
@State var is_thread_open: Bool = false
|
||||
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
|
||||
@State var is_deleted_account: Bool = false
|
||||
@State var is_profile_open: Bool = false
|
||||
@State var event: NostrEvent? = nil
|
||||
@State var active_profile: String? = nil
|
||||
@State var active_search: NostrFilter? = nil
|
||||
@State var active_event_id: String? = nil
|
||||
@State var active_event: NostrEvent? = nil
|
||||
@State var profile_open: Bool = false
|
||||
@State var thread_open: Bool = false
|
||||
@State var search_open: Bool = false
|
||||
@State var blocking: String? = nil
|
||||
@State var confirm_block: Bool = false
|
||||
@State var user_blocked_confirm: Bool = false
|
||||
@State var muting: String? = nil
|
||||
@State var confirm_mute: Bool = false
|
||||
@State var user_muted_confirm: Bool = false
|
||||
@State var confirm_overwrite_mutelist: Bool = false
|
||||
@State var filter_state : FilterState = .posts_and_replies
|
||||
@SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
|
||||
@State private var isSideBarOpened = false
|
||||
@StateObject var home: HomeModel = HomeModel()
|
||||
|
||||
@@ -99,11 +86,19 @@ struct ContentView: View {
|
||||
let sub_id = UUID().description
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
|
||||
var mystery: some View {
|
||||
Text("Are you lost?", comment: "Text asking the user if they are lost in the app.")
|
||||
.id("what")
|
||||
}
|
||||
|
||||
var PostingTimelineView: some View {
|
||||
VStack {
|
||||
ZStack {
|
||||
TabView(selection: $filter_state) {
|
||||
// This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why.
|
||||
mystery
|
||||
|
||||
contentTimelineView(filter: FilterState.posts.filter)
|
||||
.tag(FilterState.posts)
|
||||
.id(FilterState.posts)
|
||||
@@ -115,7 +110,7 @@ struct ContentView: View {
|
||||
|
||||
if privkey != nil {
|
||||
PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) {
|
||||
self.active_sheet = .post
|
||||
self.active_sheet = .post(.posting)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,28 +142,10 @@ struct ContentView: View {
|
||||
search_open = false
|
||||
isSideBarOpened = false
|
||||
}
|
||||
|
||||
var timelineNavItem: some View {
|
||||
VStack {
|
||||
switch selected_timeline {
|
||||
case .home:
|
||||
Image("damus-home")
|
||||
.resizable()
|
||||
.frame(width:30,height:30)
|
||||
.shadow(color: Color("DamusPurple"), radius: 2)
|
||||
case .dms:
|
||||
Text("DMs", comment: "Toolbar label for DMs view, where DM is the English abbreviation for Direct Message.")
|
||||
.bold()
|
||||
case .notifications:
|
||||
Text("Notifications", comment: "Toolbar label for Notifications view.")
|
||||
.bold()
|
||||
case .search:
|
||||
Text("Universe 🛸", comment: "Toolbar label for the universal view where posts from all connected relay servers appear.")
|
||||
.bold()
|
||||
case .none:
|
||||
Text(verbatim: "")
|
||||
}
|
||||
}
|
||||
|
||||
var timelineNavItem: Text {
|
||||
return Text(timeline_name(selected_timeline))
|
||||
.bold()
|
||||
}
|
||||
|
||||
func MainContent(damus: DamusState) -> some View {
|
||||
@@ -176,57 +153,60 @@ struct ContentView: View {
|
||||
NavigationLink(destination: MaybeProfileView, isActive: $profile_open) {
|
||||
EmptyView()
|
||||
}
|
||||
NavigationLink(destination: MaybeThreadView, isActive: $thread_open) {
|
||||
EmptyView()
|
||||
if let active_event {
|
||||
let thread = ThreadModel(event: active_event, damus_state: damus_state!)
|
||||
NavigationLink(destination: ThreadView(state: damus_state!, thread: thread), isActive: $thread_open) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
NavigationLink(destination: MaybeSearchView, isActive: $search_open) {
|
||||
EmptyView()
|
||||
}
|
||||
switch selected_timeline {
|
||||
case .search:
|
||||
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
|
||||
if #available(iOS 16.0, *) {
|
||||
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
|
||||
}
|
||||
|
||||
case .home:
|
||||
PostingTimelineView
|
||||
|
||||
case .notifications:
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
TimelineView(events: home.notifications, loading: $home.loading, damus: damus, show_friend_icon: true, filter: { _ in true })
|
||||
}
|
||||
NotificationsView(state: damus, notifications: home.notifications)
|
||||
|
||||
case .dms:
|
||||
DirectMessagesView(damus_state: damus_state!)
|
||||
.environmentObject(home.dms)
|
||||
|
||||
case .none:
|
||||
EmptyView()
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||
}
|
||||
}
|
||||
.navigationBarTitle(selected_timeline == .home ? NSLocalizedString("Home", comment: "Navigation bar title for Home view where posts and replies appear from those who the user is following.") : NSLocalizedString("Universe 🛸", comment: "Navigation bar title for universal view where posts from all connected relay servers appear."), displayMode: .inline)
|
||||
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
timelineNavItem
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
VStack {
|
||||
if selected_timeline == .home {
|
||||
Image("damus-home")
|
||||
.resizable()
|
||||
.frame(width:30,height:30)
|
||||
.shadow(color: DamusColors.purple, radius: 2)
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
} else {
|
||||
timelineNavItem
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
}
|
||||
|
||||
var MaybeSearchView: some View {
|
||||
Group {
|
||||
if let search = self.active_search {
|
||||
SearchView(appstate: damus_state!, search: SearchModel(contacts: damus_state!.contacts, pool: damus_state!.pool, search: search))
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var MaybeThreadView: some View {
|
||||
Group {
|
||||
if let evid = self.active_event_id {
|
||||
BuildThreadV2View(damus: damus_state!, event_id: evid)
|
||||
SearchView(appstate: damus_state!, search: SearchModel(state: damus_state!, search: search))
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -247,9 +227,9 @@ struct ContentView: View {
|
||||
|
||||
func MaybeReportView(target: ReportTarget) -> some View {
|
||||
Group {
|
||||
if let ds = damus_state {
|
||||
if let sec = ds.keypair.privkey {
|
||||
ReportView(pool: ds.pool, target: target, privkey: sec)
|
||||
if let damus_state {
|
||||
if let sec = damus_state.keypair.privkey {
|
||||
ReportView(postbox: damus_state.postbox, target: target, privkey: sec)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -259,63 +239,62 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func open_event(ev: NostrEvent) {
|
||||
self.active_event = ev
|
||||
self.thread_open = true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let damus = self.damus_state {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
TabView { // Prevents navbar appearance change on scroll
|
||||
MainContent(damus: damus)
|
||||
.toolbar() {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
isSideBarOpened.toggle()
|
||||
} label: {
|
||||
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles)
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
}
|
||||
.disabled(isSideBarOpened)
|
||||
TabView { // Prevents navbar appearance change on scroll
|
||||
MainContent(damus: damus)
|
||||
.toolbar() {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
isSideBarOpened.toggle()
|
||||
} label: {
|
||||
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles, disable_animation: damus_state!.settings.disable_animation)
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(alignment: .center) {
|
||||
if home.signal.signal != home.signal.max_signal {
|
||||
NavigationLink(destination: RelayConfigView(state: damus_state!)) {
|
||||
Text("\(home.signal.signal)/\(home.signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
// maybe expand this to other timelines in the future
|
||||
if selected_timeline == .search {
|
||||
Button(action: {
|
||||
//isFilterVisible.toggle()
|
||||
self.active_sheet = .filter
|
||||
}) {
|
||||
// checklist, checklist.checked, lisdt.bullet, list.bullet.circle, line.3.horizontal.decrease..., line.3.horizontail.decrease
|
||||
Label(NSLocalizedString("Filter", comment: "Button label text for filtering relay servers."), systemImage: "line.3.horizontal.decrease")
|
||||
.foregroundColor(.gray)
|
||||
//.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(isSideBarOpened)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(alignment: .center) {
|
||||
SignalView(state: damus_state!, signal: home.signal)
|
||||
|
||||
// maybe expand this to other timelines in the future
|
||||
if selected_timeline == .search {
|
||||
Button(action: {
|
||||
//isFilterVisible.toggle()
|
||||
self.active_sheet = .filter
|
||||
}) {
|
||||
// checklist, checklist.checked, lisdt.bullet, list.bullet.circle, line.3.horizontal.decrease..., line.3.horizontail.decrease
|
||||
Label(NSLocalizedString("Filter", comment: "Button label text for filtering relay servers."), systemImage: "line.3.horizontal.decrease")
|
||||
.foregroundColor(.gray)
|
||||
//.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.overlay(
|
||||
SideMenuView(damus_state: damus, isSidebarVisible: $isSideBarOpened.animation())
|
||||
)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
|
||||
TabBar(new_events: $home.new_events, selected: $selected_timeline, isSidebarVisible: $isSideBarOpened, action: switch_timeline)
|
||||
TabBar(new_events: $home.new_events, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
|
||||
.padding([.bottom], 8)
|
||||
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.onAppear() {
|
||||
self.connect()
|
||||
setup_notifications()
|
||||
@@ -324,14 +303,12 @@ struct ContentView: View {
|
||||
switch item {
|
||||
case .report(let target):
|
||||
MaybeReportView(target: target)
|
||||
case .post:
|
||||
PostView(replying_to: nil, references: [], damus_state: damus_state!)
|
||||
case .reply(let event):
|
||||
ReplyView(replying_to: event, damus: damus_state!)
|
||||
case .post(let action):
|
||||
PostView(action: action, damus_state: damus_state!)
|
||||
case .event:
|
||||
EventDetailView()
|
||||
case .filter:
|
||||
let timeline = selected_timeline ?? .home
|
||||
let timeline = selected_timeline
|
||||
if #available(iOS 16.0, *) {
|
||||
RelayFilterView(state: damus_state!, timeline: timeline)
|
||||
.presentationDetents([.height(550)])
|
||||
@@ -352,8 +329,11 @@ struct ContentView: View {
|
||||
active_profile = ref.ref_id
|
||||
profile_open = true
|
||||
} else if ref.key == "e" {
|
||||
active_event_id = ref.ref_id
|
||||
thread_open = true
|
||||
find_event(state: damus_state!, evid: ref.ref_id, search_type: .event, find_from: nil) { ev in
|
||||
if let ev {
|
||||
open_event(ev: ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .filter(let filt):
|
||||
active_search = filt
|
||||
@@ -363,24 +343,9 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
}
|
||||
.onReceive(handle_notify(.boost)) { notif in
|
||||
guard let privkey = self.privkey else {
|
||||
return
|
||||
}
|
||||
let ev = notif.object as! NostrEvent
|
||||
let boost = make_boost_event(pubkey: pubkey, privkey: privkey, boosted: ev)
|
||||
self.damus_state?.pool.send(.event(boost))
|
||||
}
|
||||
.onReceive(handle_notify(.open_thread)) { obj in
|
||||
//let ev = obj.object as! NostrEvent
|
||||
//thread.set_active_event(ev)
|
||||
//is_thread_open = true
|
||||
}
|
||||
.onReceive(handle_notify(.reply)) { notif in
|
||||
let ev = notif.object as! NostrEvent
|
||||
self.active_sheet = .reply(ev)
|
||||
}
|
||||
.onReceive(handle_notify(.like)) { like in
|
||||
.onReceive(handle_notify(.compose)) { notif in
|
||||
let action = notif.object as! PostAction
|
||||
self.active_sheet = .post(action)
|
||||
}
|
||||
.onReceive(handle_notify(.deleted_account)) { notif in
|
||||
self.is_deleted_account = true
|
||||
@@ -389,14 +354,20 @@ struct ContentView: View {
|
||||
let target = notif.object as! ReportTarget
|
||||
self.active_sheet = .report(target)
|
||||
}
|
||||
.onReceive(handle_notify(.block)) { notif in
|
||||
.onReceive(handle_notify(.mute)) { notif in
|
||||
let pubkey = notif.object as! String
|
||||
self.blocking = pubkey
|
||||
self.confirm_block = true
|
||||
self.muting = pubkey
|
||||
self.confirm_mute = true
|
||||
}
|
||||
.onReceive(handle_notify(.broadcast_event)) { obj in
|
||||
let ev = obj.object as! NostrEvent
|
||||
self.damus_state?.pool.send(.event(ev))
|
||||
guard let ds = self.damus_state else {
|
||||
return
|
||||
}
|
||||
ds.postbox.send(ev)
|
||||
if let profile = ds.profiles.profiles[ev.pubkey] {
|
||||
ds.postbox.send(profile.event)
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.unfollow)) { notif in
|
||||
guard let privkey = self.privkey else {
|
||||
@@ -410,7 +381,7 @@ struct ContentView: View {
|
||||
let target = notif.object as! FollowTarget
|
||||
let pk = target.pubkey
|
||||
|
||||
if let ev = unfollow_user(pool: damus.pool,
|
||||
if let ev = unfollow_user(postbox: damus.postbox,
|
||||
our_contacts: damus.contacts.event,
|
||||
pubkey: damus.pubkey,
|
||||
privkey: privkey,
|
||||
@@ -461,7 +432,16 @@ struct ContentView: View {
|
||||
//let to_relays = tup.1
|
||||
print("post \(post.content)")
|
||||
let new_ev = post_to_event(post: post, privkey: privkey, pubkey: pubkey)
|
||||
self.damus_state?.pool.send(.event(new_ev))
|
||||
guard let ds = self.damus_state else {
|
||||
return
|
||||
}
|
||||
ds.postbox.send(new_ev)
|
||||
for eref in new_ev.referenced_ids.prefix(3) {
|
||||
// also broadcast at most 3 referenced events
|
||||
if let ev = ds.events.lookup(eref.ref_id) {
|
||||
ds.postbox.send(ev)
|
||||
}
|
||||
}
|
||||
case .cancel:
|
||||
active_sheet = nil
|
||||
print("post cancelled")
|
||||
@@ -471,7 +451,56 @@ struct ContentView: View {
|
||||
self.damus_state?.pool.connect_to_disconnected()
|
||||
}
|
||||
.onReceive(handle_notify(.new_mutes)) { notif in
|
||||
home.filter_muted()
|
||||
home.filter_events()
|
||||
}
|
||||
.onReceive(handle_notify(.mute_thread)) { notif in
|
||||
home.filter_events()
|
||||
}
|
||||
.onReceive(handle_notify(.unmute_thread)) { notif in
|
||||
home.filter_events()
|
||||
}
|
||||
.onReceive(handle_notify(.local_notification)) { notif in
|
||||
|
||||
guard let local = notif.object as? LossyLocalNotification,
|
||||
let damus_state else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let target = damus_state.events.lookup(local.event_id) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch local.type {
|
||||
case .dm:
|
||||
selected_timeline = .dms
|
||||
damus_state.dms.open_dm_by_pk(target.pubkey)
|
||||
|
||||
case .like: fallthrough
|
||||
case .zap: fallthrough
|
||||
case .mention: fallthrough
|
||||
case .repost:
|
||||
open_event(ev: target)
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.onlyzaps_mode)) { notif in
|
||||
let hide = notif.object as! Bool
|
||||
home.filter_events()
|
||||
|
||||
guard let damus_state else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let profile = damus_state.profiles.lookup(id: damus_state.pubkey) else {
|
||||
return
|
||||
}
|
||||
|
||||
profile.reactions = !hide
|
||||
|
||||
guard let profile_ev = make_metadata_event(keypair: damus_state.keypair, metadata: profile) else {
|
||||
return
|
||||
}
|
||||
|
||||
damus_state.postbox.send(profile_ev)
|
||||
}
|
||||
.alert(NSLocalizedString("Deleted Account", comment: "Alert message to indicate this is a deleted account"), isPresented: $is_deleted_account) {
|
||||
Button(NSLocalizedString("Logout", comment: "Button to close the alert that informs that the current account has been deleted.")) {
|
||||
@@ -479,23 +508,23 @@ struct ContentView: View {
|
||||
notify(.logout, ())
|
||||
}
|
||||
}
|
||||
.alert(NSLocalizedString("User blocked", comment: "Alert message to indicate the user has been blocked"), isPresented: $user_blocked_confirm, actions: {
|
||||
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to block a user was successful.")) {
|
||||
user_blocked_confirm = false
|
||||
.alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
|
||||
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
|
||||
user_muted_confirm = false
|
||||
}
|
||||
}, message: {
|
||||
if let pubkey = self.blocking {
|
||||
if let pubkey = self.muting {
|
||||
let profile = damus_state!.profiles.lookup(id: pubkey)
|
||||
let name = Profile.displayName(profile: profile, pubkey: pubkey)
|
||||
Text("\(name) has been blocked", comment: "Alert message that informs a user was blocked.")
|
||||
let name = Profile.displayName(profile: profile, pubkey: pubkey).username
|
||||
Text("\(name) has been muted", comment: "Alert message that informs a user was muted.")
|
||||
} else {
|
||||
Text("User has been blocked", comment: "Alert message that informs a user was blocked.")
|
||||
Text("User has been muted", comment: "Alert message that informs a user was d.")
|
||||
}
|
||||
})
|
||||
.alert(NSLocalizedString("Create new mutelist", comment: "Title of alert prompting the user to create a new mutelist."), isPresented: $confirm_overwrite_mutelist, actions: {
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel out of alert that creates a new mutelist.")) {
|
||||
confirm_overwrite_mutelist = false
|
||||
confirm_block = false
|
||||
confirm_mute = false
|
||||
}
|
||||
|
||||
Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
|
||||
@@ -507,7 +536,7 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
|
||||
guard let pubkey = blocking else {
|
||||
guard let pubkey = muting else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -516,20 +545,20 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
damus_state?.contacts.set_mutelist(mutelist)
|
||||
ds.pool.send(.event(mutelist))
|
||||
ds.postbox.send(mutelist)
|
||||
|
||||
confirm_overwrite_mutelist = false
|
||||
confirm_block = false
|
||||
user_blocked_confirm = true
|
||||
confirm_mute = false
|
||||
user_muted_confirm = true
|
||||
}
|
||||
}, message: {
|
||||
Text("No block list found, create a new one? This will overwrite any previous block lists.", comment: "Alert message prompt that asks if the user wants to create a new block list, overwriting previous block lists.")
|
||||
Text("No mute list found, create a new one? This will overwrite any previous mute lists.", comment: "Alert message prompt that asks if the user wants to create a new mute list, overwriting previous mute lists.")
|
||||
})
|
||||
.alert(NSLocalizedString("Block User", comment: "Title of alert for blocking a user."), isPresented: $confirm_block, actions: {
|
||||
Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for blocking a user."), role: .cancel) {
|
||||
confirm_block = false
|
||||
.alert(NSLocalizedString("Mute User", comment: "Title of alert for muting a user."), isPresented: $confirm_mute, actions: {
|
||||
Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for muting a user."), role: .cancel) {
|
||||
confirm_mute = false
|
||||
}
|
||||
Button(NSLocalizedString("Block", comment: "Alert button to block a user."), role: .destructive) {
|
||||
Button(NSLocalizedString("Mute", comment: "Alert button to mute a user."), role: .destructive) {
|
||||
guard let ds = damus_state else {
|
||||
return
|
||||
}
|
||||
@@ -540,7 +569,7 @@ struct ContentView: View {
|
||||
guard let keypair = ds.keypair.to_full() else {
|
||||
return
|
||||
}
|
||||
guard let pubkey = blocking else {
|
||||
guard let pubkey = muting else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -548,21 +577,23 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
damus_state?.contacts.set_mutelist(ev)
|
||||
ds.pool.send(.event(ev))
|
||||
ds.postbox.send(ev)
|
||||
}
|
||||
}
|
||||
}, message: {
|
||||
if let pubkey = blocking {
|
||||
if let pubkey = muting {
|
||||
let profile = damus_state?.profiles.lookup(id: pubkey)
|
||||
let name = Profile.displayName(profile: profile, pubkey: pubkey)
|
||||
Text("Block \(name)?", comment: "Alert message prompt to ask if a user should be blocked.")
|
||||
let name = Profile.displayName(profile: profile, pubkey: pubkey).username
|
||||
Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.")
|
||||
} else {
|
||||
Text("Could not find user to block...", comment: "Alert message to indicate that the blocked user could not be found.")
|
||||
Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func switch_timeline(_ timeline: Timeline) {
|
||||
self.isSideBarOpened = false
|
||||
|
||||
self.popToRoot()
|
||||
NotificationCenter.default.post(name: .switched_timeline, object: timeline)
|
||||
|
||||
@@ -591,16 +622,22 @@ struct ContentView: View {
|
||||
let pool = RelayPool()
|
||||
let metadatas = RelayMetadatas()
|
||||
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
||||
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
|
||||
|
||||
let new_relay_filters = load_relay_filters(pubkey) == nil
|
||||
for relay in BOOTSTRAP_RELAYS {
|
||||
if let url = URL(string: relay) {
|
||||
for relay in bootstrap_relays {
|
||||
if let url = RelayURL(relay) {
|
||||
add_new_relay(relay_filters: relay_filters, metadatas: metadatas, pool: pool, url: url, info: .rw, new_relay_filters: new_relay_filters)
|
||||
}
|
||||
}
|
||||
|
||||
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||
|
||||
|
||||
// dumb stuff needed for property wrappers
|
||||
UserSettingsStore.pubkey = pubkey
|
||||
let settings = UserSettingsStore()
|
||||
UserSettingsStore.shared = settings
|
||||
|
||||
self.damus_state = DamusState(pool: pool,
|
||||
keypair: keypair,
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
@@ -612,10 +649,16 @@ struct ContentView: View {
|
||||
previews: PreviewCache(),
|
||||
zaps: Zaps(our_pubkey: pubkey),
|
||||
lnurls: LNUrls(),
|
||||
settings: UserSettingsStore(),
|
||||
settings: settings,
|
||||
relay_filters: relay_filters,
|
||||
relay_metadata: metadatas,
|
||||
drafts: Drafts()
|
||||
drafts: Drafts(),
|
||||
events: EventCache(),
|
||||
bookmarks: BookmarksManager(pubkey: pubkey),
|
||||
postbox: PostBox(pool: pool),
|
||||
bootstrap_relays: bootstrap_relays,
|
||||
replies: ReplyCounter(our_pubkey: pubkey),
|
||||
muted_threads: MutedThreadsManager(keypair: keypair)
|
||||
)
|
||||
home.damus_state = self.damus_state!
|
||||
|
||||
@@ -639,31 +682,6 @@ func get_since_time(last_event: NostrEvent?) -> Int64? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ws_nostr_event(relay: String, ev: WebSocketEvent) -> NostrEvent? {
|
||||
switch ev {
|
||||
case .binary(let dat):
|
||||
return NostrEvent(content: "binary data? \(dat.count) bytes", pubkey: relay)
|
||||
case .cancelled:
|
||||
return NostrEvent(content: "cancelled", pubkey: relay)
|
||||
case .connected:
|
||||
return NostrEvent(content: "connected", pubkey: relay)
|
||||
case .disconnected:
|
||||
return NostrEvent(content: "disconnected", pubkey: relay)
|
||||
case .error(let err):
|
||||
return NostrEvent(content: "error \(err.debugDescription)", pubkey: relay)
|
||||
case .text(let txt):
|
||||
return NostrEvent(content: "text \(txt)", pubkey: relay)
|
||||
case .pong:
|
||||
return NostrEvent(content: "pong", pubkey: relay)
|
||||
case .ping:
|
||||
return NostrEvent(content: "ping", pubkey: relay)
|
||||
case .viabilityChanged(let b):
|
||||
return NostrEvent(content: "viabilityChanged \(b)", pubkey: relay)
|
||||
case .reconnectSuggested(let b):
|
||||
return NostrEvent(content: "reconnectSuggested \(b)", pubkey: relay)
|
||||
}
|
||||
}
|
||||
|
||||
func is_notification(ev: NostrEvent, pubkey: String) -> Bool {
|
||||
if ev.pubkey == pubkey {
|
||||
return false
|
||||
@@ -764,3 +782,69 @@ func setup_notifications() {
|
||||
}
|
||||
}
|
||||
|
||||
func find_event(state: DamusState, evid: String, search_type: SearchType, find_from: [String]?, callback: @escaping (NostrEvent?) -> ()) {
|
||||
if let ev = state.events.lookup(evid) {
|
||||
callback(ev)
|
||||
return
|
||||
}
|
||||
|
||||
let subid = UUID().description
|
||||
|
||||
var has_event = false
|
||||
|
||||
var filter = search_type == .event ? NostrFilter.filter_ids([ evid ]) : NostrFilter.filter_authors([ evid ])
|
||||
|
||||
if search_type == .profile {
|
||||
filter.kinds = [NostrKind.metadata.rawValue]
|
||||
}
|
||||
|
||||
filter.limit = 1
|
||||
var attempts = 0
|
||||
|
||||
state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
|
||||
guard case .nostr_event(let ev) = res else {
|
||||
return
|
||||
}
|
||||
|
||||
guard ev.subid == subid else {
|
||||
return
|
||||
}
|
||||
|
||||
switch ev {
|
||||
case .ok:
|
||||
break
|
||||
case .event(_, let ev):
|
||||
has_event = true
|
||||
callback(ev)
|
||||
state.pool.unsubscribe(sub_id: subid)
|
||||
case .eose:
|
||||
if !has_event {
|
||||
attempts += 1
|
||||
if attempts == state.pool.descriptors.count / 2 {
|
||||
callback(nil)
|
||||
}
|
||||
state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
}
|
||||
case .notice(_):
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func timeline_name(_ timeline: Timeline?) -> String {
|
||||
guard let timeline else {
|
||||
return ""
|
||||
}
|
||||
switch timeline {
|
||||
case .home:
|
||||
return NSLocalizedString("Home", comment: "Navigation bar title for Home view where posts and replies appear from those who the user is following.")
|
||||
case .notifications:
|
||||
return NSLocalizedString("Notifications", comment: "Toolbar label for Notifications view.")
|
||||
case .search:
|
||||
return NSLocalizedString("Universe 🛸", comment: "Toolbar label for the universal view where posts from all connected relay servers appear.")
|
||||
case .dms:
|
||||
return NSLocalizedString("DMs", comment: "Toolbar label for DMs view, where DM is the English abbreviation for Direct Message.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,16 @@
|
||||
<string>nostr</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>io.damus</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>damus</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
@@ -36,5 +46,9 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Damus needs access to your camera if you want to upload photos from it</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Damus needs access to your microphone if you want to upload recorded videos from it</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,34 +11,52 @@ import Foundation
|
||||
class ActionBarModel: ObservableObject {
|
||||
@Published var our_like: NostrEvent?
|
||||
@Published var our_boost: NostrEvent?
|
||||
@Published var our_reply: NostrEvent?
|
||||
@Published var our_zap: Zap?
|
||||
@Published var likes: Int
|
||||
@Published var boosts: Int
|
||||
@Published var zaps: Int
|
||||
@Published var zap_total: Int64
|
||||
@Published var replies: Int
|
||||
|
||||
static func empty() -> ActionBarModel {
|
||||
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, our_like: nil, our_boost: nil, our_zap: nil)
|
||||
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
|
||||
}
|
||||
|
||||
init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?) {
|
||||
init() {
|
||||
self.our_like = nil
|
||||
self.our_boost = nil
|
||||
self.our_reply = nil
|
||||
self.our_zap = nil
|
||||
self.likes = 0
|
||||
self.boosts = 0
|
||||
self.zaps = 0
|
||||
self.zap_total = 0
|
||||
self.replies = 0
|
||||
}
|
||||
|
||||
init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, replies: Int, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?, our_reply: NostrEvent?) {
|
||||
self.likes = likes
|
||||
self.boosts = boosts
|
||||
self.zaps = zaps
|
||||
self.replies = replies
|
||||
self.zap_total = zap_total
|
||||
self.our_like = our_like
|
||||
self.our_boost = our_boost
|
||||
self.our_zap = our_zap
|
||||
self.our_reply = our_reply
|
||||
}
|
||||
|
||||
func update(damus: DamusState, evid: String) {
|
||||
self.likes = damus.likes.counts[evid] ?? 0
|
||||
self.boosts = damus.boosts.counts[evid] ?? 0
|
||||
self.zaps = damus.zaps.event_counts[evid] ?? 0
|
||||
self.replies = damus.replies.get_replies(evid)
|
||||
self.zap_total = damus.zaps.event_totals[evid] ?? 0
|
||||
self.our_like = damus.likes.our_events[evid]
|
||||
self.our_boost = damus.boosts.our_events[evid]
|
||||
self.our_zap = damus.zaps.our_zaps[evid]?.first
|
||||
self.our_reply = damus.replies.our_reply(evid)
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
@@ -54,6 +72,10 @@ class ActionBarModel: ObservableObject {
|
||||
return our_like != nil
|
||||
}
|
||||
|
||||
var replied: Bool {
|
||||
return our_reply != nil
|
||||
}
|
||||
|
||||
var boosted: Bool {
|
||||
return our_boost != nil
|
||||
}
|
||||
|
||||
@@ -7,44 +7,65 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class BookmarksManager {
|
||||
fileprivate func get_bookmarks_key(pubkey: String) -> String {
|
||||
pk_setting_key(pubkey, key: "bookmarks")
|
||||
}
|
||||
|
||||
func load_bookmarks(pubkey: String) -> [NostrEvent] {
|
||||
let key = get_bookmarks_key(pubkey: pubkey)
|
||||
return (UserDefaults.standard.stringArray(forKey: key) ?? []).compactMap {
|
||||
event_from_json(dat: $0)
|
||||
}
|
||||
}
|
||||
|
||||
func save_bookmarks(pubkey: String, current_value: [NostrEvent], value: [NostrEvent]) -> Bool {
|
||||
let uniq_bookmarks = uniq(value)
|
||||
|
||||
if uniq_bookmarks != current_value {
|
||||
let encoded = uniq_bookmarks.map(event_to_json)
|
||||
UserDefaults.standard.set(encoded, forKey: get_bookmarks_key(pubkey: pubkey))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class BookmarksManager: ObservableObject {
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let pubkey: String
|
||||
|
||||
init(pubkey: String) {
|
||||
self.pubkey = pubkey
|
||||
}
|
||||
|
||||
var bookmarks: [String] {
|
||||
private var _bookmarks: [NostrEvent]
|
||||
var bookmarks: [NostrEvent] {
|
||||
get {
|
||||
return userDefaults.stringArray(forKey: storageKey()) ?? []
|
||||
return _bookmarks
|
||||
}
|
||||
set {
|
||||
let uniqueBookmarks = Array(Set(newValue))
|
||||
if uniqueBookmarks != bookmarks {
|
||||
userDefaults.set(uniqueBookmarks, forKey: storageKey())
|
||||
if save_bookmarks(pubkey: pubkey, current_value: _bookmarks, value: newValue) {
|
||||
self._bookmarks = newValue
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isBookmarked(_ string: String) -> Bool {
|
||||
return bookmarks.contains(string)
|
||||
init(pubkey: String) {
|
||||
self._bookmarks = load_bookmarks(pubkey: pubkey)
|
||||
self.pubkey = pubkey
|
||||
}
|
||||
|
||||
func updateBookmark(_ string: String) {
|
||||
if isBookmarked(string) {
|
||||
bookmarks = bookmarks.filter { $0 != string }
|
||||
func isBookmarked(_ ev: NostrEvent) -> Bool {
|
||||
return bookmarks.contains(ev)
|
||||
}
|
||||
|
||||
func updateBookmark(_ ev: NostrEvent) {
|
||||
if isBookmarked(ev) {
|
||||
bookmarks = bookmarks.filter { $0 != ev }
|
||||
} else {
|
||||
bookmarks.append(string)
|
||||
bookmarks.insert(ev, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
bookmarks = []
|
||||
}
|
||||
|
||||
private func storageKey() -> String {
|
||||
pk_setting_key(pubkey, key: "bookmarks")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func follow_user(pool: RelayPool, our_contacts: NostrEvent?, pubkey: String, pri
|
||||
return ev
|
||||
}
|
||||
|
||||
func unfollow_user(pool: RelayPool, our_contacts: NostrEvent?, pubkey: String, privkey: String, unfollow: String) -> NostrEvent? {
|
||||
func unfollow_user(postbox: PostBox, our_contacts: NostrEvent?, pubkey: String, privkey: String, unfollow: String) -> NostrEvent? {
|
||||
guard let cs = our_contacts else {
|
||||
return nil
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func unfollow_user(pool: RelayPool, our_contacts: NostrEvent?, pubkey: String, p
|
||||
ev.calculate_id()
|
||||
ev.sign(privkey: privkey)
|
||||
|
||||
pool.send(.event(ev))
|
||||
postbox.send(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func follow_with_existing_contacts(our_pubkey: String, our_contacts: NostrEvent,
|
||||
|
||||
func make_contact_relays(_ relays: [RelayDescriptor]) -> [String: RelayInfo] {
|
||||
return relays.reduce(into: [:]) { acc, relay in
|
||||
acc[relay.url.absoluteString] = relay.info
|
||||
acc[relay.url.url.absoluteString] = relay.info
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class CreateAccountModel: ObservableObject {
|
||||
@Published var about: String = ""
|
||||
@Published var pubkey: String = ""
|
||||
@Published var privkey: String = ""
|
||||
@Published var profile_image: String? = nil
|
||||
|
||||
var pubkey_bech32: String {
|
||||
return bech32_pubkey(self.pubkey) ?? ""
|
||||
|
||||
@@ -24,6 +24,20 @@ struct DamusState {
|
||||
let relay_filters: RelayFilters
|
||||
let relay_metadata: RelayMetadatas
|
||||
let drafts: Drafts
|
||||
let events: EventCache
|
||||
let bookmarks: BookmarksManager
|
||||
let postbox: PostBox
|
||||
let bootstrap_relays: [String]
|
||||
let replies: ReplyCounter
|
||||
let muted_threads: MutedThreadsManager
|
||||
|
||||
@discardableResult
|
||||
func add_zap(zap: Zap) -> Bool {
|
||||
// store generic zap mapping
|
||||
self.zaps.add_zap(zap: zap)
|
||||
// associate with events as well
|
||||
return self.events.store_zap(zap: zap)
|
||||
}
|
||||
|
||||
var pubkey: String {
|
||||
return keypair.pubkey
|
||||
@@ -32,9 +46,9 @@ struct DamusState {
|
||||
var is_privkey_user: Bool {
|
||||
keypair.privkey != nil
|
||||
}
|
||||
|
||||
|
||||
static var settings_pubkey: String? = nil
|
||||
|
||||
static var empty: DamusState {
|
||||
return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts())
|
||||
}
|
||||
return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""), muted_threads: MutedThreadsManager(keypair: Keypair(pubkey: "", privkey: nil))) }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,19 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum DeepLPlan: String, CaseIterable, Identifiable {
|
||||
enum DeepLPlan: String, CaseIterable, Identifiable, StringCodable {
|
||||
init?(from string: String) {
|
||||
guard let dl = DeepLPlan(rawValue: string) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self = dl
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
struct Model: Identifiable, Hashable {
|
||||
|
||||
@@ -16,6 +16,8 @@ class DirectMessageModel: ObservableObject {
|
||||
|
||||
@Published var draft: String
|
||||
|
||||
let pubkey: String
|
||||
|
||||
var is_request: Bool
|
||||
var our_pubkey: String
|
||||
|
||||
@@ -29,17 +31,19 @@ class DirectMessageModel: ObservableObject {
|
||||
return true
|
||||
}
|
||||
|
||||
init(events: [NostrEvent], our_pubkey: String) {
|
||||
init(events: [NostrEvent], our_pubkey: String, pubkey: String) {
|
||||
self.events = events
|
||||
self.is_request = false
|
||||
self.our_pubkey = our_pubkey
|
||||
self.draft = ""
|
||||
self.pubkey = pubkey
|
||||
}
|
||||
|
||||
init(our_pubkey: String) {
|
||||
init(our_pubkey: String, pubkey: String) {
|
||||
self.events = []
|
||||
self.is_request = false
|
||||
self.our_pubkey = our_pubkey
|
||||
self.draft = ""
|
||||
self.pubkey = pubkey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,43 @@
|
||||
import Foundation
|
||||
|
||||
class DirectMessagesModel: ObservableObject {
|
||||
@Published var dms: [(String, DirectMessageModel)] = []
|
||||
@Published var dms: [DirectMessageModel] = []
|
||||
@Published var loading: Bool = false
|
||||
@Published var open_dm: Bool = false
|
||||
@Published private(set) var active_model: DirectMessageModel = DirectMessageModel(our_pubkey: "", pubkey: "")
|
||||
let our_pubkey: String
|
||||
|
||||
init(our_pubkey: String) {
|
||||
self.our_pubkey = our_pubkey
|
||||
}
|
||||
|
||||
var message_requests: [(String, DirectMessageModel)] {
|
||||
return dms.filter { dm in dm.1.is_request }
|
||||
var message_requests: [DirectMessageModel] {
|
||||
return dms.filter { dm in dm.is_request }
|
||||
}
|
||||
|
||||
var friend_dms: [(String, DirectMessageModel)] {
|
||||
return dms.filter { dm in !dm.1.is_request }
|
||||
var friend_dms: [DirectMessageModel] {
|
||||
return dms.filter { dm in !dm.is_request }
|
||||
}
|
||||
|
||||
func set_active_dm_model(_ model: DirectMessageModel) {
|
||||
self.active_model = model
|
||||
}
|
||||
|
||||
func open_dm_by_pk(_ pubkey: String) {
|
||||
self.set_active_dm(pubkey)
|
||||
self.open_dm = true
|
||||
}
|
||||
|
||||
func open_dm_by_model(_ model: DirectMessageModel) {
|
||||
self.set_active_dm_model(model)
|
||||
self.open_dm = true
|
||||
}
|
||||
|
||||
func set_active_dm(_ pubkey: String) {
|
||||
for model in self.dms where model.pubkey == pubkey {
|
||||
self.set_active_dm_model(model)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func lookup_or_create(_ pubkey: String) -> DirectMessageModel {
|
||||
@@ -29,15 +52,15 @@ class DirectMessagesModel: ObservableObject {
|
||||
return dm
|
||||
}
|
||||
|
||||
let new = DirectMessageModel(our_pubkey: our_pubkey)
|
||||
dms.append((pubkey, new))
|
||||
let new = DirectMessageModel(our_pubkey: our_pubkey, pubkey: pubkey)
|
||||
dms.append(new)
|
||||
return new
|
||||
}
|
||||
|
||||
func lookup(_ pubkey: String) -> DirectMessageModel? {
|
||||
for dm in dms {
|
||||
if pubkey == dm.0 {
|
||||
return dm.1
|
||||
if pubkey == dm.pubkey {
|
||||
return dm
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,23 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class Drafts: ObservableObject {
|
||||
@Published var post: String = ""
|
||||
@Published var replies: [NostrEvent: String] = [:]
|
||||
class DraftArtifacts {
|
||||
var content: NSMutableAttributedString
|
||||
var media: [UploadedMedia]
|
||||
|
||||
init() {
|
||||
self.content = NSMutableAttributedString(string: "")
|
||||
self.media = []
|
||||
}
|
||||
|
||||
init(content: NSMutableAttributedString, media: [UploadedMedia]) {
|
||||
self.content = content
|
||||
self.media = media
|
||||
}
|
||||
}
|
||||
|
||||
class Drafts: ObservableObject {
|
||||
@Published var post: DraftArtifacts? = nil
|
||||
@Published var replies: [NostrEvent: DraftArtifacts] = [:]
|
||||
@Published var quotes: [NostrEvent: DraftArtifacts] = [:]
|
||||
}
|
||||
|
||||
@@ -74,8 +74,12 @@ func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> {
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
if m.type == type {
|
||||
acc.insert(m.index)
|
||||
if let idx = m.index {
|
||||
acc.insert(idx)
|
||||
}
|
||||
}
|
||||
case .relay:
|
||||
return
|
||||
case .text:
|
||||
return
|
||||
case .hashtag:
|
||||
|
||||
@@ -64,8 +64,10 @@ class EventsModel: ObservableObject {
|
||||
handle_event(relay_id: relay_id, ev: ev)
|
||||
case .notice(_):
|
||||
break
|
||||
case .ok:
|
||||
break
|
||||
case .eose(_):
|
||||
load_profiles(profiles_subid: profiles_id, relay_id: relay_id, events: events, damus_state: state)
|
||||
load_profiles(profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class FollowersModel: ObservableObject {
|
||||
if ev.known_kind == .contacts {
|
||||
handle_contact_event(ev)
|
||||
} else if ev.known_kind == .metadata {
|
||||
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
}
|
||||
|
||||
case .notice(let msg):
|
||||
@@ -94,6 +94,9 @@ class FollowersModel: ObservableObject {
|
||||
} else if sub_id == self.profiles_id {
|
||||
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
||||
}
|
||||
|
||||
case .ok:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class FollowingModel {
|
||||
}
|
||||
|
||||
func get_filter() -> NostrFilter {
|
||||
var f = NostrFilter.filter_kinds([0])
|
||||
var f = NostrFilter.filter_kinds([NostrKind.metadata.rawValue])
|
||||
f.authors = self.contacts.reduce(into: Array<String>()) { acc, pk in
|
||||
// don't fetch profiles we already have
|
||||
if damus_state.profiles.lookup(id: pk) != nil {
|
||||
@@ -58,9 +58,11 @@ class FollowingModel {
|
||||
break
|
||||
case .nostr_event(let nev):
|
||||
switch nev {
|
||||
case .ok:
|
||||
break
|
||||
case .event(_, let ev):
|
||||
if ev.kind == 0 {
|
||||
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
}
|
||||
case .notice(let msg):
|
||||
print("followingmodel notice: \(msg)")
|
||||
|
||||
@@ -8,26 +8,19 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct NewEventsBits {
|
||||
let bits: Int
|
||||
|
||||
init() {
|
||||
bits = 0
|
||||
}
|
||||
|
||||
init (prev: NewEventsBits, setting: Timeline) {
|
||||
self.bits = prev.bits | timeline_bit(setting)
|
||||
}
|
||||
|
||||
init (prev: NewEventsBits, unsetting: Timeline) {
|
||||
self.bits = prev.bits & ~timeline_bit(unsetting)
|
||||
}
|
||||
|
||||
func is_set(_ timeline: Timeline) -> Bool {
|
||||
let notification_bit = timeline_bit(timeline)
|
||||
return (bits & notification_bit) == notification_bit
|
||||
}
|
||||
|
||||
struct NewEventsBits: OptionSet {
|
||||
let rawValue: Int
|
||||
|
||||
static let home = NewEventsBits(rawValue: 1 << 0)
|
||||
static let zaps = NewEventsBits(rawValue: 1 << 1)
|
||||
static let mentions = NewEventsBits(rawValue: 1 << 2)
|
||||
static let reposts = NewEventsBits(rawValue: 1 << 3)
|
||||
static let likes = NewEventsBits(rawValue: 1 << 4)
|
||||
static let search = NewEventsBits(rawValue: 1 << 5)
|
||||
static let dms = NewEventsBits(rawValue: 1 << 6)
|
||||
|
||||
static let all = NewEventsBits(rawValue: 0xFFFFFFFF)
|
||||
static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions]
|
||||
}
|
||||
|
||||
class HomeModel: ObservableObject {
|
||||
@@ -48,38 +41,27 @@ class HomeModel: ObservableObject {
|
||||
let dms_subid = UUID().description
|
||||
let init_subid = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
|
||||
var loading: Bool = false
|
||||
|
||||
var signal = SignalModel()
|
||||
|
||||
@Published var new_events: NewEventsBits = NewEventsBits()
|
||||
@Published var notifications: EventHolder
|
||||
@Published var dms: DirectMessagesModel
|
||||
@Published var events: EventHolder
|
||||
@Published var loading: Bool = false
|
||||
@Published var signal: SignalModel = SignalModel()
|
||||
@Published var notifications = NotificationsModel()
|
||||
@Published var events = EventHolder()
|
||||
|
||||
init() {
|
||||
self.events = EventHolder()
|
||||
self.notifications = EventHolder()
|
||||
self.damus_state = DamusState.empty
|
||||
self.dms = DirectMessagesModel(our_pubkey: "")
|
||||
}
|
||||
|
||||
init(damus_state: DamusState) {
|
||||
self.events = EventHolder()
|
||||
self.notifications = EventHolder()
|
||||
self.damus_state = damus_state
|
||||
self.dms = DirectMessagesModel(our_pubkey: damus_state.pubkey)
|
||||
filter_events()
|
||||
self.setup_debouncer()
|
||||
}
|
||||
|
||||
|
||||
var pool: RelayPool {
|
||||
return damus_state.pool
|
||||
}
|
||||
|
||||
func setup_debouncer() {
|
||||
// turn off debouncer after initial load
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||
self.should_debounce_dms = false
|
||||
}
|
||||
var dms: DirectMessagesModel {
|
||||
return damus_state.dms
|
||||
}
|
||||
|
||||
func has_sub_id_event(sub_id: String, ev_id: String) -> Bool {
|
||||
@@ -90,6 +72,13 @@ class HomeModel: ObservableObject {
|
||||
|
||||
return has_event[sub_id]!.contains(ev_id)
|
||||
}
|
||||
|
||||
func setup_debouncer() {
|
||||
// turn off debouncer after initial load
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||
self.should_debounce_dms = false
|
||||
}
|
||||
}
|
||||
|
||||
func process_event(sub_id: String, relay_id: String, ev: NostrEvent) {
|
||||
if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) {
|
||||
@@ -129,36 +118,49 @@ class HomeModel: ObservableObject {
|
||||
handle_channel_meta(ev)
|
||||
case .zap:
|
||||
handle_zap_event(ev)
|
||||
case .zap_request:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func handle_zap_event_with_zapper(_ ev: NostrEvent, our_pubkey: String, zapper: String) {
|
||||
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper) else {
|
||||
func handle_zap_event_with_zapper(profiles: Profiles, ev: NostrEvent, our_keypair: Keypair, zapper: String) {
|
||||
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else {
|
||||
return
|
||||
}
|
||||
|
||||
damus_state.zaps.add_zap(zap: zap)
|
||||
damus_state.add_zap(zap: zap)
|
||||
|
||||
guard zap.target.pubkey == our_pubkey else {
|
||||
guard zap.target.pubkey == our_keypair.pubkey else {
|
||||
return
|
||||
}
|
||||
|
||||
if !notifications.insert(ev) {
|
||||
if !notifications.insert_zap(zap, damus_state: damus_state) {
|
||||
return
|
||||
}
|
||||
|
||||
handle_last_event(ev: ev, timeline: .notifications)
|
||||
|
||||
if handle_last_event(ev: ev, timeline: .notifications) {
|
||||
if damus_state.settings.zap_vibration {
|
||||
// Generate zap vibration
|
||||
zap_vibrate(zap_amount: zap.invoice.amount)
|
||||
}
|
||||
if damus_state.settings.zap_notification {
|
||||
// Create in-app local notification for zap received.
|
||||
create_in_app_zap_notification(profiles: profiles, zap: zap, evId: ev.referenced_ids.first?.id ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
func handle_zap_event(_ ev: NostrEvent) {
|
||||
// These are zap notifications
|
||||
guard let ptag = event_tag(ev, name: "p") else {
|
||||
return
|
||||
}
|
||||
|
||||
let our_keypair = damus_state.keypair
|
||||
if let local_zapper = damus_state.profiles.lookup_zapper(pubkey: ptag) {
|
||||
handle_zap_event_with_zapper(ev, our_pubkey: damus_state.pubkey, zapper: local_zapper)
|
||||
handle_zap_event_with_zapper(profiles: self.damus_state.profiles, ev: ev, our_keypair: our_keypair, zapper: local_zapper)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -177,34 +179,38 @@ class HomeModel: ObservableObject {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.damus_state.profiles.zappers[ptag] = zapper
|
||||
self.handle_zap_event_with_zapper(ev, our_pubkey: self.damus_state.pubkey, zapper: zapper)
|
||||
self.handle_zap_event_with_zapper(profiles: self.damus_state.profiles, ev: ev, our_keypair: our_keypair, zapper: zapper)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func handle_channel_create(_ ev: NostrEvent) {
|
||||
guard ev.is_valid else {
|
||||
return
|
||||
}
|
||||
|
||||
self.channels[ev.id] = ev
|
||||
}
|
||||
|
||||
func handle_channel_meta(_ ev: NostrEvent) {
|
||||
}
|
||||
|
||||
func filter_muted() {
|
||||
events.filter { !damus_state.contacts.is_muted($0.pubkey) }
|
||||
self.dms.dms = dms.dms.filter { !damus_state.contacts.is_muted($0.0) }
|
||||
notifications.filter { !damus_state.contacts.is_muted($0.pubkey) }
|
||||
func filter_events() {
|
||||
events.filter { ev in
|
||||
!damus_state.contacts.is_muted(ev.pubkey)
|
||||
}
|
||||
|
||||
self.dms.dms = dms.dms.filter { ev in
|
||||
!damus_state.contacts.is_muted(ev.pubkey)
|
||||
}
|
||||
|
||||
notifications.filter { ev in
|
||||
if damus_state.settings.onlyzaps_mode && ev.known_kind == NostrKind.like {
|
||||
return false
|
||||
}
|
||||
|
||||
return !damus_state.contacts.is_muted(ev.pubkey) && !damus_state.muted_threads.isMutedThread(ev, privkey: damus_state.keypair.privkey)
|
||||
}
|
||||
}
|
||||
|
||||
func handle_delete_event(_ ev: NostrEvent) {
|
||||
guard ev.is_valid else {
|
||||
return
|
||||
}
|
||||
|
||||
self.deleted_events.insert(ev.id)
|
||||
}
|
||||
|
||||
@@ -223,16 +229,22 @@ class HomeModel: ObservableObject {
|
||||
func handle_boost_event(sub_id: String, _ ev: NostrEvent) {
|
||||
var boost_ev_id = ev.last_refid()?.ref_id
|
||||
|
||||
if let inner_ev = ev.inner_event {
|
||||
if let inner_ev = ev.get_inner_event(cache: damus_state.events) {
|
||||
boost_ev_id = inner_ev.id
|
||||
|
||||
guard inner_ev.is_valid else {
|
||||
return
|
||||
}
|
||||
|
||||
if inner_ev.is_textlike {
|
||||
handle_text_event(sub_id: sub_id, ev)
|
||||
|
||||
Task.init {
|
||||
guard validate_event(ev: inner_ev) == .ok else {
|
||||
return
|
||||
}
|
||||
|
||||
if inner_ev.is_textlike {
|
||||
DispatchQueue.main.async {
|
||||
self.handle_text_event(sub_id: sub_id, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
guard let e = boost_ev_id else {
|
||||
@@ -255,15 +267,18 @@ class HomeModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// CHECK SIGS ON THESE
|
||||
if damus_state.settings.onlyzaps_mode {
|
||||
return
|
||||
}
|
||||
|
||||
switch damus_state.likes.add_event(ev, target: e.ref_id) {
|
||||
case .already_counted:
|
||||
break
|
||||
case .success(let n):
|
||||
handle_notification(ev: ev)
|
||||
let liked = Counted(event: ev, id: e.ref_id, total: n)
|
||||
notify(.liked, liked)
|
||||
notify(.update_stats, e.ref_id)
|
||||
//notify(.liked, liked)
|
||||
//notify(.update_stats, e.ref_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,34 +296,28 @@ class HomeModel: ObservableObject {
|
||||
send_home_filters(relay_id: relay_id)
|
||||
}
|
||||
case .error(let merr):
|
||||
let desc = merr.debugDescription
|
||||
let desc = String(describing: merr)
|
||||
if desc.contains("Software caused connection abort") {
|
||||
pool.reconnect(to: [relay_id])
|
||||
}
|
||||
case .disconnected: fallthrough
|
||||
case .cancelled:
|
||||
case .disconnected:
|
||||
pool.reconnect(to: [relay_id])
|
||||
case .reconnectSuggested(let t):
|
||||
if t {
|
||||
pool.reconnect(to: [relay_id])
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
update_signal_from_pool(signal: signal, pool: damus_state.pool)
|
||||
|
||||
print("ws_event \(ev)")
|
||||
|
||||
update_signal_from_pool(signal: self.signal, pool: damus_state.pool)
|
||||
case .nostr_event(let ev):
|
||||
switch ev {
|
||||
case .event(let sub_id, let ev):
|
||||
// globally handle likes
|
||||
/*
|
||||
let always_process = sub_id == notifications_subid || sub_id == contacts_subid || sub_id == home_subid || sub_id == dms_subid || sub_id == init_subid || ev.known_kind == .like || ev.known_kind == .boost || ev.known_kind == .zap || ev.known_kind == .contacts || ev.known_kind == .metadata
|
||||
if !always_process {
|
||||
// TODO: other views like threads might have their own sub ids, so ignore those events... or should we?
|
||||
return
|
||||
}
|
||||
*/
|
||||
|
||||
self.process_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
|
||||
case .notice(let msg):
|
||||
@@ -318,16 +327,22 @@ class HomeModel: ObservableObject {
|
||||
case .eose(let sub_id):
|
||||
|
||||
if sub_id == dms_subid {
|
||||
var dms = dms.dms.flatMap { $0.1.events }
|
||||
var dms = dms.dms.flatMap { $0.events }
|
||||
dms.append(contentsOf: incoming_dms)
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, events: dms, damus_state: damus_state)
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state)
|
||||
} else if sub_id == notifications_subid {
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, events: notifications.all_events, damus_state: damus_state)
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state)
|
||||
} else if sub_id == home_subid {
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus_state)
|
||||
}
|
||||
|
||||
self.loading = false
|
||||
break
|
||||
|
||||
case .ok:
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,13 +363,13 @@ class HomeModel: ObservableObject {
|
||||
var friends = damus_state.contacts.get_friend_list()
|
||||
friends.append(damus_state.pubkey)
|
||||
|
||||
var contacts_filter = NostrFilter.filter_kinds([0])
|
||||
var contacts_filter = NostrFilter.filter_kinds([NostrKind.metadata.rawValue])
|
||||
contacts_filter.authors = friends
|
||||
|
||||
var our_contacts_filter = NostrFilter.filter_kinds([3, 0])
|
||||
var our_contacts_filter = NostrFilter.filter_kinds([NostrKind.contacts.rawValue, NostrKind.metadata.rawValue])
|
||||
our_contacts_filter.authors = [damus_state.pubkey]
|
||||
|
||||
var our_blocklist_filter = NostrFilter.filter_kinds([30000])
|
||||
var our_blocklist_filter = NostrFilter.filter_kinds([NostrKind.list.rawValue])
|
||||
our_blocklist_filter.parameter = ["mute"]
|
||||
our_blocklist_filter.authors = [damus_state.pubkey]
|
||||
|
||||
@@ -373,25 +388,29 @@ class HomeModel: ObservableObject {
|
||||
our_dms_filter.authors = [ damus_state.pubkey ]
|
||||
|
||||
// TODO: separate likes?
|
||||
var home_filter = NostrFilter.filter_kinds([
|
||||
var home_filter_kinds = [
|
||||
NostrKind.text.rawValue,
|
||||
NostrKind.chat.rawValue,
|
||||
NostrKind.like.rawValue,
|
||||
NostrKind.boost.rawValue,
|
||||
])
|
||||
NostrKind.boost.rawValue
|
||||
]
|
||||
if !damus_state.settings.onlyzaps_mode {
|
||||
home_filter_kinds.append(NostrKind.like.rawValue)
|
||||
}
|
||||
var home_filter = NostrFilter.filter_kinds(home_filter_kinds)
|
||||
// include our pubkey as well even if we're not technically a friend
|
||||
home_filter.authors = friends
|
||||
home_filter.limit = 500
|
||||
|
||||
var notifications_filter = NostrFilter.filter_kinds([
|
||||
var notifications_filter_kinds = [
|
||||
NostrKind.text.rawValue,
|
||||
NostrKind.chat.rawValue,
|
||||
NostrKind.like.rawValue,
|
||||
NostrKind.boost.rawValue,
|
||||
NostrKind.zap.rawValue,
|
||||
])
|
||||
]
|
||||
if !damus_state.settings.onlyzaps_mode {
|
||||
notifications_filter_kinds.append(NostrKind.like.rawValue)
|
||||
}
|
||||
var notifications_filter = NostrFilter.filter_kinds(notifications_filter_kinds)
|
||||
notifications_filter.pubkeys = [damus_state.pubkey]
|
||||
notifications_filter.limit = 100
|
||||
notifications_filter.limit = 500
|
||||
|
||||
var home_filters = [home_filter]
|
||||
var notifications_filters = [notifications_filter]
|
||||
@@ -444,7 +463,7 @@ class HomeModel: ObservableObject {
|
||||
}
|
||||
|
||||
func handle_metadata_event(_ ev: NostrEvent) {
|
||||
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
}
|
||||
|
||||
func get_last_event_of_kind(relay_id: String, kind: Int) -> NostrEvent? {
|
||||
@@ -455,22 +474,44 @@ class HomeModel: ObservableObject {
|
||||
|
||||
return m[kind]
|
||||
}
|
||||
|
||||
|
||||
func handle_notification(ev: NostrEvent) {
|
||||
// don't show notifications from ourselves
|
||||
guard ev.pubkey != damus_state.pubkey else {
|
||||
return
|
||||
}
|
||||
|
||||
guard event_has_our_pubkey(ev, our_pubkey: self.damus_state.pubkey) else {
|
||||
return
|
||||
}
|
||||
|
||||
if !notifications.insert(ev) {
|
||||
guard should_show_event(contacts: damus_state.contacts, ev: ev) && !damus_state.muted_threads.isMutedThread(ev, privkey: damus_state.keypair.privkey) else {
|
||||
return
|
||||
}
|
||||
|
||||
handle_last_event(ev: ev, timeline: .notifications)
|
||||
damus_state.events.insert(ev)
|
||||
|
||||
if let inner_ev = ev.get_inner_event(cache: damus_state.events) {
|
||||
damus_state.events.insert(inner_ev)
|
||||
}
|
||||
|
||||
if !notifications.insert_event(ev, damus_state: damus_state) {
|
||||
return
|
||||
}
|
||||
|
||||
if handle_last_event(ev: ev, timeline: .notifications) {
|
||||
process_local_notification(damus_state: damus_state, event: ev)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func handle_last_event(ev: NostrEvent, timeline: Timeline, shouldNotify: Bool = true) {
|
||||
|
||||
@discardableResult
|
||||
func handle_last_event(ev: NostrEvent, timeline: Timeline, shouldNotify: Bool = true) -> Bool {
|
||||
if let new_bits = handle_last_events(new_events: self.new_events, ev: ev, timeline: timeline, shouldNotify: shouldNotify) {
|
||||
new_events = new_bits
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,10 +521,16 @@ class HomeModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func handle_text_event(sub_id: String, _ ev: NostrEvent) {
|
||||
guard should_show_event(contacts: damus_state.contacts, ev: ev) else {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: will we need to process this in other places like zap request contents, etc?
|
||||
process_image_metadata(cache: damus_state.events, ev: ev)
|
||||
damus_state.replies.count_replies(ev)
|
||||
damus_state.events.insert(ev)
|
||||
|
||||
if sub_id == home_subid {
|
||||
insert_home_event(ev)
|
||||
@@ -492,15 +539,26 @@ class HomeModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) {
|
||||
self.new_events = notifs
|
||||
if damus_state.settings.dm_notification {
|
||||
let convo = ev.decrypted(privkey: self.damus_state.keypair.privkey) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
|
||||
let notify = LocalNotification(type: .dm, event: ev, target: ev, content: convo)
|
||||
create_local_notification(profiles: damus_state.profiles, notify: notify)
|
||||
}
|
||||
}
|
||||
|
||||
func handle_dm(_ ev: NostrEvent) {
|
||||
guard should_show_event(contacts: damus_state.contacts, ev: ev) else {
|
||||
return
|
||||
}
|
||||
|
||||
damus_state.events.insert(ev)
|
||||
|
||||
if !should_debounce_dms {
|
||||
self.incoming_dms.append(ev)
|
||||
if let notifs = handle_incoming_dms(prev_events: self.new_events, dms: self.dms, our_pubkey: self.damus_state.pubkey, evs: self.incoming_dms) {
|
||||
self.new_events = notifs
|
||||
got_new_dm(notifs: notifs, ev: ev)
|
||||
}
|
||||
self.incoming_dms = []
|
||||
return
|
||||
@@ -508,9 +566,9 @@ class HomeModel: ObservableObject {
|
||||
|
||||
incoming_dms.append(ev)
|
||||
|
||||
dm_debouncer.debounce {
|
||||
dm_debouncer.debounce { [self] in
|
||||
if let notifs = handle_incoming_dms(prev_events: self.new_events, dms: self.dms, our_pubkey: self.damus_state.pubkey, evs: self.incoming_dms) {
|
||||
self.new_events = notifs
|
||||
got_new_dm(notifs: notifs, ev: ev)
|
||||
}
|
||||
self.incoming_dms = []
|
||||
}
|
||||
@@ -523,8 +581,8 @@ func update_signal_from_pool(signal: SignalModel, pool: RelayPool) {
|
||||
signal.max_signal = pool.relays.count
|
||||
}
|
||||
|
||||
if signal.signal != pool.num_connecting {
|
||||
signal.signal = signal.max_signal - pool.num_connecting
|
||||
if signal.signal != pool.num_connected {
|
||||
signal.signal = pool.num_connected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,15 +676,9 @@ func print_filters(relay_id: String?, filters groups: [[NostrFilter]]) {
|
||||
print("-----")
|
||||
}
|
||||
|
||||
func process_metadata_event(our_pubkey: String, profiles: Profiles, ev: NostrEvent) {
|
||||
guard let profile: Profile = decode_data(Data(ev.content.utf8)) else {
|
||||
return
|
||||
}
|
||||
|
||||
func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: Profile, ev: NostrEvent) {
|
||||
if our_pubkey == ev.pubkey && (profile.deleted ?? false) {
|
||||
DispatchQueue.main.async {
|
||||
notify(.deleted_account, ())
|
||||
}
|
||||
notify(.deleted_account, ())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -639,10 +691,11 @@ func process_metadata_event(our_pubkey: String, profiles: Profiles, ev: NostrEve
|
||||
}
|
||||
}
|
||||
|
||||
let tprof = TimestampedProfile(profile: profile, timestamp: ev.created_at)
|
||||
let tprof = TimestampedProfile(profile: profile, timestamp: ev.created_at, event: ev)
|
||||
profiles.add(id: ev.pubkey, profile: tprof)
|
||||
|
||||
if let nip05 = profile.nip05, old_nip05 != profile.nip05 {
|
||||
|
||||
Task.detached(priority: .background) {
|
||||
let validated = await validate_nip05(pubkey: ev.pubkey, nip05_str: nip05)
|
||||
if validated != nil {
|
||||
@@ -651,27 +704,71 @@ func process_metadata_event(our_pubkey: String, profiles: Profiles, ev: NostrEve
|
||||
|
||||
DispatchQueue.main.async {
|
||||
profiles.validated[ev.pubkey] = validated
|
||||
profiles.nip05_pubkey[nip05] = ev.pubkey
|
||||
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// load pfps asap
|
||||
|
||||
var changed = false
|
||||
|
||||
let picture = tprof.profile.picture ?? robohash(ev.pubkey)
|
||||
if URL(string: picture) != nil {
|
||||
DispatchQueue.main.async {
|
||||
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
let banner = tprof.profile.banner ?? ""
|
||||
if URL(string: banner) != nil {
|
||||
DispatchQueue.main.async {
|
||||
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
|
||||
if changed {
|
||||
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
|
||||
}
|
||||
}
|
||||
|
||||
func guard_valid_event(events: EventCache, ev: NostrEvent, callback: @escaping () -> Void) {
|
||||
let validated = events.is_event_valid(ev.id)
|
||||
|
||||
switch validated {
|
||||
case .unknown:
|
||||
Task {
|
||||
let result = validate_event(ev: ev)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
events.store_event_validation(evid: ev.id, validated: result)
|
||||
guard result == .ok else {
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
case .ok:
|
||||
callback()
|
||||
|
||||
case .bad_id: fallthrough
|
||||
case .bad_sig:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func process_metadata_event(events: EventCache, our_pubkey: String, profiles: Profiles, ev: NostrEvent) {
|
||||
guard_valid_event(events: events, ev: ev) {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
guard let profile: Profile = decode_data(Data(ev.content.utf8)) else {
|
||||
return
|
||||
}
|
||||
|
||||
profile.cache_lnurl()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
process_metadata_profile(our_pubkey: our_pubkey, profiles: profiles, profile: profile, ev: ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func robohash(_ pk: String) -> String {
|
||||
@@ -704,7 +801,7 @@ func process_contact_event(state: DamusState, ev: NostrEvent) {
|
||||
|
||||
func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
||||
let bootstrap_dict: [String: RelayInfo] = [:]
|
||||
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? BOOTSTRAP_RELAYS.reduce(into: bootstrap_dict) { (d, r) in
|
||||
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? state.bootstrap_relays.reduce(into: bootstrap_dict) { (d, r) in
|
||||
d[r] = .rw
|
||||
}
|
||||
|
||||
@@ -730,7 +827,7 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
||||
for d in diff {
|
||||
changed = true
|
||||
if new.contains(d) {
|
||||
if let url = URL(string: d) {
|
||||
if let url = RelayURL(d) {
|
||||
add_new_relay(relay_filters: state.relay_filters, metadatas: state.relay_metadata, pool: state.pool, url: url, info: decoded[d] ?? .rw, new_relay_filters: new_relay_filters)
|
||||
}
|
||||
} else {
|
||||
@@ -739,14 +836,15 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
||||
}
|
||||
|
||||
if changed {
|
||||
save_bootstrap_relays(pubkey: state.pubkey, relays: Array(new))
|
||||
notify(.relays_changed, ())
|
||||
}
|
||||
}
|
||||
|
||||
func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, url: URL, info: RelayInfo, new_relay_filters: Bool) {
|
||||
func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, url: RelayURL, info: RelayInfo, new_relay_filters: Bool) {
|
||||
try? pool.add_relay(url, info: info)
|
||||
|
||||
let relay_id = url.absoluteString
|
||||
let relay_id = url.id
|
||||
guard metadatas.lookup(relay_id: relay_id) == nil else {
|
||||
return
|
||||
}
|
||||
@@ -811,10 +909,10 @@ func handle_incoming_dm(ev: NostrEvent, our_pubkey: String, dms: DirectMessagesM
|
||||
}
|
||||
}
|
||||
|
||||
for (pk, _) in dms.dms {
|
||||
if pk == the_pk {
|
||||
for model in dms.dms {
|
||||
if model.pubkey == the_pk {
|
||||
found = true
|
||||
inserted = insert_uniq_sorted_event(events: &(dms.dms[i].1.events), new_ev: ev) {
|
||||
inserted = insert_uniq_sorted_event(events: &(dms.dms[i].events), new_ev: ev) {
|
||||
$0.created_at < $1.created_at
|
||||
}
|
||||
|
||||
@@ -824,8 +922,8 @@ func handle_incoming_dm(ev: NostrEvent, our_pubkey: String, dms: DirectMessagesM
|
||||
}
|
||||
|
||||
if !found {
|
||||
let model = DirectMessageModel(events: [ev], our_pubkey: our_pubkey)
|
||||
dms.dms.append((the_pk, model))
|
||||
let model = DirectMessageModel(events: [ev], our_pubkey: our_pubkey, pubkey: the_pk)
|
||||
dms.dms.append(model)
|
||||
inserted = true
|
||||
}
|
||||
|
||||
@@ -852,14 +950,58 @@ func handle_incoming_dms(prev_events: NewEventsBits, dms: DirectMessagesModel, o
|
||||
}
|
||||
|
||||
if inserted {
|
||||
dms.dms = dms.dms.filter({ $0.1.events.count > 0 }).sorted { a, b in
|
||||
return a.1.events.last!.created_at > b.1.events.last!.created_at
|
||||
Task.init {
|
||||
let new_dms = Array(dms.dms.filter({ $0.events.count > 0 })).sorted { a, b in
|
||||
return a.events.last!.created_at > b.events.last!.created_at
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
dms.dms = new_dms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new_events
|
||||
}
|
||||
|
||||
func determine_event_notifications(_ ev: NostrEvent) -> NewEventsBits {
|
||||
guard let kind = ev.known_kind else {
|
||||
return []
|
||||
}
|
||||
|
||||
if kind == .zap {
|
||||
return [.zaps]
|
||||
}
|
||||
|
||||
if kind == .boost {
|
||||
return [.reposts]
|
||||
}
|
||||
|
||||
if kind == .text {
|
||||
return [.mentions]
|
||||
}
|
||||
|
||||
if kind == .like {
|
||||
return [.likes]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
func timeline_to_notification_bits(_ timeline: Timeline, ev: NostrEvent?) -> NewEventsBits {
|
||||
switch timeline {
|
||||
case .home:
|
||||
return [.home]
|
||||
case .notifications:
|
||||
if let ev {
|
||||
return determine_event_notifications(ev)
|
||||
}
|
||||
return [.notifications]
|
||||
case .search:
|
||||
return [.search]
|
||||
case .dms:
|
||||
return [.dms]
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper to determine if we need to notify the user of new events
|
||||
func handle_last_events(new_events: NewEventsBits, ev: NostrEvent, timeline: Timeline, shouldNotify: Bool = true) -> NewEventsBits? {
|
||||
@@ -868,7 +1010,7 @@ func handle_last_events(new_events: NewEventsBits, ev: NostrEvent, timeline: Tim
|
||||
if last_ev == nil || last_ev!.created_at < ev.created_at {
|
||||
save_last_event(ev, timeline: timeline)
|
||||
if shouldNotify {
|
||||
return NewEventsBits(prev: new_events, setting: timeline)
|
||||
return new_events.union(timeline_to_notification_bits(timeline, ev: ev))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -895,3 +1037,143 @@ func should_show_event(contacts: Contacts, ev: NostrEvent) -> Bool {
|
||||
return ev.should_show_event
|
||||
}
|
||||
|
||||
func zap_vibrate(zap_amount: Int64) {
|
||||
let sats = zap_amount / 1000
|
||||
var vibration_generator: UIImpactFeedbackGenerator
|
||||
if sats >= 10000 {
|
||||
vibration_generator = UIImpactFeedbackGenerator(style: .heavy)
|
||||
} else if sats >= 1000 {
|
||||
vibration_generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
} else {
|
||||
vibration_generator = UIImpactFeedbackGenerator(style: .light)
|
||||
}
|
||||
vibration_generator.impactOccurred()
|
||||
}
|
||||
|
||||
func zap_notification_title(_ zap: Zap) -> String {
|
||||
if zap.private_request != nil {
|
||||
return NSLocalizedString("Private Zap", comment: "Title of notification when a private zap is received.")
|
||||
} else {
|
||||
return NSLocalizedString("Zap", comment: "Title of notification when a non-private zap is received.")
|
||||
}
|
||||
}
|
||||
|
||||
func zap_notification_body(profiles: Profiles, zap: Zap, locale: Locale = Locale.current) -> String {
|
||||
let src = zap.private_request ?? zap.request.ev
|
||||
let anon = event_is_anonymous(ev: src)
|
||||
let pk = anon ? "anon" : src.pubkey
|
||||
let profile = profiles.lookup(id: pk)
|
||||
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
|
||||
let formattedSats = format_msats_abbrev(zap.invoice.amount)
|
||||
let name = Profile.displayName(profile: profile, pubkey: pk).display_name
|
||||
|
||||
if src.content.isEmpty {
|
||||
let format = localizedStringFormat(key: "zap_notification_no_message", locale: locale)
|
||||
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name)
|
||||
} else {
|
||||
let format = localizedStringFormat(key: "zap_notification_with_message", locale: locale)
|
||||
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name, src.content)
|
||||
}
|
||||
}
|
||||
|
||||
func create_in_app_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, evId: String) {
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
content.title = zap_notification_title(zap)
|
||||
content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
||||
content.sound = UNNotificationSound.default
|
||||
content.userInfo = LossyLocalNotification(type: .zap, event_id: evId).to_user_info()
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
|
||||
let request = UNNotificationRequest(identifier: "myZapNotification", content: content, trigger: trigger)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
print("Error: \(error)")
|
||||
} else {
|
||||
print("Local notification scheduled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func process_local_notification(damus_state: DamusState, event ev: NostrEvent) {
|
||||
guard let type = ev.known_kind else {
|
||||
return
|
||||
}
|
||||
|
||||
if damus_state.settings.notification_only_from_following,
|
||||
damus_state.contacts.follow_state(ev.pubkey) != .follows
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
// Don't show notifications from muted threads.
|
||||
if damus_state.muted_threads.isMutedThread(ev, privkey: damus_state.keypair.privkey) {
|
||||
return
|
||||
}
|
||||
|
||||
if type == .text && damus_state.settings.mention_notification {
|
||||
let blocks = ev.blocks(damus_state.keypair.privkey)
|
||||
for case .mention(let mention) in blocks where mention.ref.ref_id == damus_state.keypair.pubkey {
|
||||
let content = NSAttributedString(render_note_content(ev: ev, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey).content.attributed).string
|
||||
|
||||
let notify = LocalNotification(type: .mention, event: ev, target: ev, content: content)
|
||||
create_local_notification(profiles: damus_state.profiles, notify: notify )
|
||||
}
|
||||
} else if type == .boost && damus_state.settings.repost_notification, let inner_ev = ev.get_inner_event(cache: damus_state.events) {
|
||||
let notify = LocalNotification(type: .repost, event: ev, target: inner_ev, content: inner_ev.content)
|
||||
create_local_notification(profiles: damus_state.profiles, notify: notify)
|
||||
} else if type == .like && damus_state.settings.like_notification,
|
||||
let evid = ev.referenced_ids.last?.ref_id,
|
||||
let liked_event = damus_state.events.lookup(evid)
|
||||
{
|
||||
let notify = LocalNotification(type: .like, event: ev, target: liked_event, content: liked_event.content)
|
||||
create_local_notification(profiles: damus_state.profiles, notify: notify)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func create_local_notification(profiles: Profiles, notify: LocalNotification) {
|
||||
let content = UNMutableNotificationContent()
|
||||
var title = ""
|
||||
var identifier = ""
|
||||
|
||||
let displayName = event_author_name(profiles: profiles, pubkey: notify.event.pubkey)
|
||||
|
||||
switch notify.type {
|
||||
case .mention:
|
||||
title = String(format: NSLocalizedString("Mentioned by %@", comment: "Mentioned by heading in local notification"), displayName)
|
||||
identifier = "myMentionNotification"
|
||||
case .repost:
|
||||
title = String(format: NSLocalizedString("Reposted by %@", comment: "Reposted by heading in local notification"), displayName)
|
||||
identifier = "myBoostNotification"
|
||||
case .like:
|
||||
title = String(format: NSLocalizedString("Liked by %@", comment: "Liked by heading in local notification"), displayName)
|
||||
identifier = "myLikeNotification"
|
||||
case .dm:
|
||||
title = String(format: NSLocalizedString("%@", comment: "DM by heading in local notification"), displayName)
|
||||
identifier = "myDMNotification"
|
||||
case .zap:
|
||||
// not handled here
|
||||
break
|
||||
}
|
||||
content.title = title
|
||||
content.body = notify.content
|
||||
content.sound = UNNotificationSound.default
|
||||
content.userInfo = notify.to_lossy().to_user_info()
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
print("Error: \(error)")
|
||||
} else {
|
||||
print("Local notification scheduled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
63
damus/Models/ImageUploadModel.swift
Normal file
63
damus/Models/ImageUploadModel.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// ImageUploadModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-03-16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
|
||||
enum MediaUpload {
|
||||
case image(URL)
|
||||
case video(URL)
|
||||
|
||||
var genericFileName: String {
|
||||
"damus_generic_filename.\(file_extension)"
|
||||
}
|
||||
|
||||
var file_extension: String {
|
||||
switch self {
|
||||
case .image(let url):
|
||||
return url.pathExtension
|
||||
case .video(let url):
|
||||
return url.pathExtension
|
||||
}
|
||||
}
|
||||
|
||||
var localURL: URL {
|
||||
switch self {
|
||||
case .image(let url):
|
||||
return url
|
||||
case .video(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
var is_image: Bool {
|
||||
if case .image = self {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
|
||||
@Published var progress: Double? = nil
|
||||
|
||||
func start(media: MediaUpload, uploader: MediaUploader) async -> ImageUploadResult {
|
||||
let res = await create_upload_request(mediaToUpload: media, mediaUploader: uploader, progress: self)
|
||||
DispatchQueue.main.async {
|
||||
self.progress = nil
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
|
||||
DispatchQueue.main.async {
|
||||
self.progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum LibreTranslateServer: String, CaseIterable, Identifiable {
|
||||
enum LibreTranslateServer: String, CaseIterable, Identifiable, StringCodable {
|
||||
var id: String { self.rawValue }
|
||||
|
||||
struct Model: Identifiable, Hashable {
|
||||
@@ -17,9 +17,19 @@ enum LibreTranslateServer: String, CaseIterable, Identifiable {
|
||||
var url: String?
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
init?(from string: String) {
|
||||
guard let libreTranslateServer = LibreTranslateServer(rawValue: string) else {
|
||||
return nil
|
||||
}
|
||||
self = libreTranslateServer
|
||||
}
|
||||
|
||||
case argosopentech
|
||||
case terraprint
|
||||
case vern
|
||||
case custom
|
||||
|
||||
var model: Model {
|
||||
@@ -28,8 +38,6 @@ enum LibreTranslateServer: String, CaseIterable, Identifiable {
|
||||
return .init(tag: self.rawValue, displayName: "translate.argosopentech.com", url: "https://translate.argosopentech.com")
|
||||
case .terraprint:
|
||||
return .init(tag: self.rawValue, displayName: "translate.terraprint.co", url: "https://translate.terraprint.co")
|
||||
case .vern:
|
||||
return .init(tag: self.rawValue, displayName: "lt.vern.cc", url: "https://lt.vern.cc")
|
||||
case .custom:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("Custom", comment: "Dropdown option for selecting a custom translation server."), url: nil)
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// LocalUserConfig.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-06-15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
struct LocalUserConfig: Codable {
|
||||
let relays: [RelayDescriptor]
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ enum MentionType {
|
||||
}
|
||||
}
|
||||
|
||||
struct Mention {
|
||||
let index: Int
|
||||
struct Mention: Equatable {
|
||||
let index: Int?
|
||||
let type: MentionType
|
||||
let ref: ReferencedId
|
||||
}
|
||||
@@ -58,12 +58,30 @@ struct LightningInvoice<T> {
|
||||
}
|
||||
}
|
||||
|
||||
enum Block {
|
||||
enum Block: Equatable {
|
||||
static func == (lhs: Block, rhs: Block) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.text(let a), .text(let b)):
|
||||
return a == b
|
||||
case (.mention(let a), .mention(let b)):
|
||||
return a == b
|
||||
case (.hashtag(let a), .hashtag(let b)):
|
||||
return a == b
|
||||
case (.url(let a), .url(let b)):
|
||||
return a == b
|
||||
case (.invoice(let a), .invoice(let b)):
|
||||
return a.string == b.string
|
||||
case (_, _):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
case text(String)
|
||||
case mention(Mention)
|
||||
case hashtag(String)
|
||||
case url(URL)
|
||||
case invoice(Invoice)
|
||||
case relay(String)
|
||||
|
||||
var is_invoice: Invoice? {
|
||||
if case .invoice(let invoice) = self {
|
||||
@@ -94,6 +112,14 @@ enum Block {
|
||||
return nil
|
||||
}
|
||||
|
||||
var is_note_mention: Bool {
|
||||
guard case .mention(let mention) = self else {
|
||||
return false
|
||||
}
|
||||
|
||||
return mention.type == .event
|
||||
}
|
||||
|
||||
var is_mention: Bool {
|
||||
if case .mention = self {
|
||||
return true
|
||||
@@ -106,7 +132,17 @@ func render_blocks(blocks: [Block]) -> String {
|
||||
return blocks.reduce("") { str, block in
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
return str + "#[\(m.index)]"
|
||||
if let idx = m.index {
|
||||
return str + "#[\(idx)]"
|
||||
} else if m.type == .pubkey, let pk = bech32_pubkey(m.ref.ref_id) {
|
||||
return str + "nostr:\(pk)"
|
||||
} else if let note_id = bech32_note_id(m.ref.ref_id) {
|
||||
return str + "nostr:\(note_id)"
|
||||
} else {
|
||||
return str + m.ref.ref_id
|
||||
}
|
||||
case .relay(let relay):
|
||||
return str + relay
|
||||
case .text(let txt):
|
||||
return str + txt
|
||||
case .hashtag(let htag):
|
||||
@@ -169,14 +205,16 @@ func convert_block(_ b: block_t, tags: [[String]]) -> Block? {
|
||||
return nil
|
||||
}
|
||||
return .text(str)
|
||||
} else if b.type == BLOCK_MENTION {
|
||||
return convert_mention_block(ind: b.block.mention, tags: tags)
|
||||
} else if b.type == BLOCK_MENTION_INDEX {
|
||||
return convert_mention_index_block(ind: b.block.mention_index, tags: tags)
|
||||
} else if b.type == BLOCK_URL {
|
||||
return convert_url_block(b.block.str)
|
||||
} else if b.type == BLOCK_INVOICE {
|
||||
return convert_invoice_block(b.block.invoice)
|
||||
} else if b.type == BLOCK_MENTION_BECH32 {
|
||||
return convert_mention_bech32_block(b.block.mention_bech32)
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -274,8 +312,8 @@ func format_msats(_ msat: Int64, locale: Locale = Locale.current) -> String {
|
||||
let sats = NSNumber(value: (Double(msat) / 1000.0))
|
||||
let formattedSats = numberFormatter.string(from: sats) ?? sats.stringValue
|
||||
|
||||
let bundle = bundleForLocale(locale: locale)
|
||||
return String(format: bundle.localizedString(forKey: "sats_count", value: nil, table: nil), locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats)
|
||||
let format = localizedStringFormat(key: "sats_count", locale: locale)
|
||||
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats)
|
||||
}
|
||||
|
||||
func convert_invoice_block(_ b: invoice_block) -> Block? {
|
||||
@@ -299,6 +337,60 @@ func convert_invoice_block(_ b: invoice_block) -> Block? {
|
||||
return .invoice(Invoice(description: description, amount: amount, string: invstr, expiry: b11.expiry, payment_hash: payment_hash, created_at: created_at))
|
||||
}
|
||||
|
||||
func convert_mention_bech32_block(_ b: mention_bech32_block) -> Block?
|
||||
{
|
||||
switch b.bech32.type {
|
||||
case NOSTR_BECH32_NOTE:
|
||||
let note = b.bech32.data.note;
|
||||
let event_id = hex_encode(Data(bytes: note.event_id, count: 32))
|
||||
let event_id_ref = ReferencedId(ref_id: event_id, relay_id: nil, key: "e")
|
||||
return .mention(Mention(index: nil, type: .event, ref: event_id_ref))
|
||||
|
||||
case NOSTR_BECH32_NEVENT:
|
||||
let nevent = b.bech32.data.nevent;
|
||||
let event_id = hex_encode(Data(bytes: nevent.event_id, count: 32))
|
||||
var relay_id: String? = nil
|
||||
if nevent.relays.num_relays > 0 {
|
||||
relay_id = strblock_to_string(nevent.relays.relays.0)
|
||||
}
|
||||
let event_id_ref = ReferencedId(ref_id: event_id, relay_id: relay_id, key: "e")
|
||||
return .mention(Mention(index: nil, type: .event, ref: event_id_ref))
|
||||
|
||||
case NOSTR_BECH32_NPUB:
|
||||
let npub = b.bech32.data.npub
|
||||
let pubkey = hex_encode(Data(bytes: npub.pubkey, count: 32))
|
||||
let pubkey_ref = ReferencedId(ref_id: pubkey, relay_id: nil, key: "p")
|
||||
return .mention(Mention(index: nil, type: .pubkey, ref: pubkey_ref))
|
||||
|
||||
case NOSTR_BECH32_NPROFILE:
|
||||
let nprofile = b.bech32.data.nprofile
|
||||
let pubkey = hex_encode(Data(bytes: nprofile.pubkey, count: 32))
|
||||
var relay_id: String? = nil
|
||||
if nprofile.relays.num_relays > 0 {
|
||||
relay_id = strblock_to_string(nprofile.relays.relays.0)
|
||||
}
|
||||
let pubkey_ref = ReferencedId(ref_id: pubkey, relay_id: relay_id, key: "p")
|
||||
return .mention(Mention(index: nil, type: .pubkey, ref: pubkey_ref))
|
||||
|
||||
case NOSTR_BECH32_NRELAY:
|
||||
let nrelay = b.bech32.data.nrelay
|
||||
guard let relay_str = strblock_to_string(nrelay.relay) else {
|
||||
return nil
|
||||
}
|
||||
return .relay(relay_str)
|
||||
|
||||
case NOSTR_BECH32_NADDR:
|
||||
// TODO: wtf do I do with this
|
||||
guard let naddr = strblock_to_string(b.str) else {
|
||||
return nil
|
||||
}
|
||||
return .text("nostr:" + naddr)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func convert_invoice_description(b11: bolt11) -> InvoiceDescription? {
|
||||
if let desc = b11.description {
|
||||
return .description(String(cString: desc))
|
||||
@@ -311,7 +403,7 @@ func convert_invoice_description(b11: bolt11) -> InvoiceDescription? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func convert_mention_block(ind: Int32, tags: [[String]]) -> Block?
|
||||
func convert_mention_index_block(ind: Int32, tags: [[String]]) -> Block?
|
||||
{
|
||||
let ind = Int(ind)
|
||||
|
||||
@@ -549,7 +641,7 @@ func parse_mention_type(_ c: String) -> MentionType? {
|
||||
}
|
||||
|
||||
/// Convert
|
||||
func make_post_tags(post_blocks: [PostBlock], tags: [[String]]) -> PostTags {
|
||||
func make_post_tags(post_blocks: [PostBlock], tags: [[String]], silent_mentions: Bool) -> PostTags {
|
||||
var new_tags = tags
|
||||
var blocks: [Block] = []
|
||||
|
||||
@@ -559,6 +651,14 @@ func make_post_tags(post_blocks: [PostBlock], tags: [[String]]) -> PostTags {
|
||||
guard let mention_type = parse_mention_type(ref.key) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if silent_mentions || mention_type == .event {
|
||||
let mention = Mention(index: nil, type: mention_type, ref: ref)
|
||||
let block = Block.mention(mention)
|
||||
blocks.append(block)
|
||||
continue
|
||||
}
|
||||
|
||||
if let ind = find_tag_ref(type: ref.key, id: ref.ref_id, tags: tags) {
|
||||
let mention = Mention(index: ind, type: mention_type, ref: ref)
|
||||
let block = Block.mention(mention)
|
||||
@@ -582,9 +682,9 @@ func make_post_tags(post_blocks: [PostBlock], tags: [[String]]) -> PostTags {
|
||||
}
|
||||
|
||||
func post_to_event(post: NostrPost, privkey: String, pubkey: String) -> NostrEvent {
|
||||
let tags = post.references.map(refid_to_tag)
|
||||
let tags = post.references.map(refid_to_tag) + post.tags
|
||||
let post_blocks = parse_post_blocks(content: post.content)
|
||||
let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags)
|
||||
let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags, silent_mentions: false)
|
||||
let content = render_blocks(blocks: post_tags.blocks)
|
||||
let new_ev = NostrEvent(content: content, pubkey: pubkey, kind: post.kind.rawValue, tags: post_tags.tags)
|
||||
new_ev.calculate_id()
|
||||
|
||||
76
damus/Models/MutedThreadsManager.swift
Normal file
76
damus/Models/MutedThreadsManager.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// MutedThreadsManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 4/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
fileprivate func getMutedThreadsKey(pubkey: String) -> String {
|
||||
pk_setting_key(pubkey, key: "muted_threads")
|
||||
}
|
||||
|
||||
func loadMutedThreads(pubkey: String) -> [String] {
|
||||
let key = getMutedThreadsKey(pubkey: pubkey)
|
||||
return UserDefaults.standard.stringArray(forKey: key) ?? []
|
||||
}
|
||||
|
||||
func saveMutedThreads(pubkey: String, currentValue: [String], value: [String]) -> Bool {
|
||||
let uniqueMutedThreads = Array(Set(value))
|
||||
|
||||
if uniqueMutedThreads != currentValue {
|
||||
UserDefaults.standard.set(uniqueMutedThreads, forKey: getMutedThreadsKey(pubkey: pubkey))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class MutedThreadsManager: ObservableObject {
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let keypair: Keypair
|
||||
|
||||
private var _mutedThreadsSet: Set<String>
|
||||
private var _mutedThreads: [String]
|
||||
var mutedThreads: [String] {
|
||||
get {
|
||||
return _mutedThreads
|
||||
}
|
||||
set {
|
||||
if saveMutedThreads(pubkey: keypair.pubkey, currentValue: _mutedThreads, value: newValue) {
|
||||
self._mutedThreads = newValue
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(keypair: Keypair) {
|
||||
self._mutedThreads = loadMutedThreads(pubkey: keypair.pubkey)
|
||||
self._mutedThreadsSet = Set(_mutedThreads)
|
||||
self.keypair = keypair
|
||||
}
|
||||
|
||||
func isMutedThread(_ ev: NostrEvent, privkey: String?) -> Bool {
|
||||
return _mutedThreadsSet.contains(ev.thread_id(privkey: privkey))
|
||||
}
|
||||
|
||||
func updateMutedThread(_ ev: NostrEvent) {
|
||||
let threadId = ev.thread_id(privkey: nil)
|
||||
if isMutedThread(ev, privkey: keypair.privkey) {
|
||||
mutedThreads = mutedThreads.filter { $0 != threadId }
|
||||
_mutedThreadsSet.remove(threadId)
|
||||
notify(.unmute_thread, ev)
|
||||
} else {
|
||||
mutedThreads.append(threadId)
|
||||
_mutedThreadsSet.insert(threadId)
|
||||
notify(.mute_thread, ev)
|
||||
}
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
mutedThreads = []
|
||||
_mutedThreadsSet.removeAll()
|
||||
}
|
||||
}
|
||||
50
damus/Models/Notifications/EventGroup.swift
Normal file
50
damus/Models/Notifications/EventGroup.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// ReactionGroup.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class EventGroup {
|
||||
var events: [NostrEvent]
|
||||
|
||||
var last_event_at: Int64 {
|
||||
guard let first = self.events.first else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return first.created_at
|
||||
}
|
||||
|
||||
init() {
|
||||
self.events = []
|
||||
}
|
||||
|
||||
init(events: [NostrEvent]) {
|
||||
self.events = events
|
||||
}
|
||||
|
||||
func insert(_ ev: NostrEvent) -> Bool {
|
||||
return insert_uniq_sorted_event_created(events: &events, new_ev: ev)
|
||||
}
|
||||
|
||||
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
|
||||
for ev in events {
|
||||
if !isIncluded(ev) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func filter(_ isIncluded: (NostrEvent) -> Bool) -> EventGroup? {
|
||||
let new_evs = events.filter(isIncluded)
|
||||
guard new_evs.count > 0 else {
|
||||
return nil
|
||||
}
|
||||
return EventGroup(events: new_evs)
|
||||
}
|
||||
}
|
||||
76
damus/Models/Notifications/ZapGroup.swift
Normal file
76
damus/Models/Notifications/ZapGroup.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// ZapGroup.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ZapGroup {
|
||||
var zaps: [Zap]
|
||||
var msat_total: Int64
|
||||
var zappers: Set<String>
|
||||
|
||||
var last_event_at: Int64 {
|
||||
guard let first = zaps.first else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return first.event.created_at
|
||||
}
|
||||
|
||||
func zap_requests() -> [NostrEvent] {
|
||||
zaps.map { z in
|
||||
if let priv = z.private_request {
|
||||
return priv
|
||||
} else {
|
||||
return z.request.ev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
|
||||
for zap in zaps {
|
||||
if !isIncluded(zap.request_ev) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func filter(_ isIncluded: (NostrEvent) -> Bool) -> ZapGroup? {
|
||||
let new_zaps = zaps.filter { isIncluded($0.request_ev) }
|
||||
guard new_zaps.count > 0 else {
|
||||
return nil
|
||||
}
|
||||
let grp = ZapGroup()
|
||||
for zap in new_zaps {
|
||||
grp.insert(zap)
|
||||
}
|
||||
return grp
|
||||
}
|
||||
|
||||
init() {
|
||||
self.zaps = []
|
||||
self.msat_total = 0
|
||||
self.zappers = Set()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func insert(_ zap: Zap) -> Bool {
|
||||
if !insert_uniq_sorted_zap_by_created(zaps: &zaps, new_zap: zap) {
|
||||
return false
|
||||
}
|
||||
|
||||
msat_total += zap.invoice.amount
|
||||
|
||||
if !zappers.contains(zap.request.ev.pubkey) {
|
||||
zappers.insert(zap.request.ev.pubkey)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
359
damus/Models/NotificationsModel.swift
Normal file
359
damus/Models/NotificationsModel.swift
Normal file
@@ -0,0 +1,359 @@
|
||||
//
|
||||
// NotificationsModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NotificationItem {
|
||||
case repost(String, EventGroup)
|
||||
case reaction(String, EventGroup)
|
||||
case profile_zap(ZapGroup)
|
||||
case event_zap(String, ZapGroup)
|
||||
case reply(NostrEvent)
|
||||
|
||||
var is_reply: NostrEvent? {
|
||||
if case .reply(let ev) = self {
|
||||
return ev
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var is_zap: ZapGroup? {
|
||||
switch self {
|
||||
case .profile_zap(let zapgrp):
|
||||
return zapgrp
|
||||
case .event_zap(_, let zapgrp):
|
||||
return zapgrp
|
||||
case .reaction:
|
||||
return nil
|
||||
case .reply:
|
||||
return nil
|
||||
case .repost:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .repost(let evid, _):
|
||||
return "repost_" + evid
|
||||
case .reaction(let evid, _):
|
||||
return "reaction_" + evid
|
||||
case .profile_zap:
|
||||
return "profile_zap"
|
||||
case .event_zap(let evid, _):
|
||||
return "event_zap_" + evid
|
||||
case .reply(let ev):
|
||||
return "reply_" + ev.id
|
||||
}
|
||||
}
|
||||
|
||||
var last_event_at: Int64 {
|
||||
switch self {
|
||||
case .reaction(_, let evgrp):
|
||||
return evgrp.last_event_at
|
||||
case .repost(_, let evgrp):
|
||||
return evgrp.last_event_at
|
||||
case .profile_zap(let zapgrp):
|
||||
return zapgrp.last_event_at
|
||||
case .event_zap(_, let zapgrp):
|
||||
return zapgrp.last_event_at
|
||||
case .reply(let reply):
|
||||
return reply.created_at
|
||||
}
|
||||
}
|
||||
|
||||
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
|
||||
switch self {
|
||||
case .repost(_, let evgrp):
|
||||
return evgrp.would_filter(isIncluded)
|
||||
case .reaction(_, let evgrp):
|
||||
return evgrp.would_filter(isIncluded)
|
||||
case .profile_zap(let zapgrp):
|
||||
return zapgrp.would_filter(isIncluded)
|
||||
case .event_zap(_, let zapgrp):
|
||||
return zapgrp.would_filter(isIncluded)
|
||||
case .reply(let ev):
|
||||
return !isIncluded(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func filter(_ isIncluded: (NostrEvent) -> Bool) -> NotificationItem? {
|
||||
switch self {
|
||||
case .repost(let evid, let evgrp):
|
||||
return evgrp.filter(isIncluded).map { .repost(evid, $0) }
|
||||
case .reaction(let evid, let evgrp):
|
||||
return evgrp.filter(isIncluded).map { .reaction(evid, $0) }
|
||||
case .profile_zap(let zapgrp):
|
||||
return zapgrp.filter(isIncluded).map { .profile_zap($0) }
|
||||
case .event_zap(let evid, let zapgrp):
|
||||
return zapgrp.filter(isIncluded).map { .event_zap(evid, $0) }
|
||||
case .reply(let ev):
|
||||
if isIncluded(ev) { return .reply(ev) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
var incoming_zaps: [Zap]
|
||||
var incoming_events: [NostrEvent]
|
||||
var should_queue: Bool
|
||||
|
||||
// mappings from events to
|
||||
var zaps: [String: ZapGroup]
|
||||
var profile_zaps: ZapGroup
|
||||
var reactions: [String: EventGroup]
|
||||
var reposts: [String: EventGroup]
|
||||
var replies: [NostrEvent]
|
||||
var has_reply: Set<String>
|
||||
var has_ev: Set<String>
|
||||
|
||||
@Published var notifications: [NotificationItem]
|
||||
|
||||
init() {
|
||||
self.zaps = [:]
|
||||
self.reactions = [:]
|
||||
self.reposts = [:]
|
||||
self.replies = []
|
||||
self.has_reply = Set()
|
||||
self.should_queue = true
|
||||
self.incoming_zaps = []
|
||||
self.incoming_events = []
|
||||
self.profile_zaps = ZapGroup()
|
||||
self.notifications = []
|
||||
self.has_ev = Set()
|
||||
}
|
||||
|
||||
func set_should_queue(_ val: Bool) {
|
||||
self.should_queue = val
|
||||
}
|
||||
|
||||
func uniq_pubkeys() -> [String] {
|
||||
var pks = Set<String>()
|
||||
|
||||
for ev in incoming_events {
|
||||
pks.insert(ev.pubkey)
|
||||
}
|
||||
|
||||
for grp in reposts {
|
||||
for ev in grp.value.events {
|
||||
pks.insert(ev.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
for ev in replies {
|
||||
pks.insert(ev.pubkey)
|
||||
}
|
||||
|
||||
for zap in incoming_zaps {
|
||||
pks.insert(zap.request.ev.pubkey)
|
||||
}
|
||||
|
||||
return Array(pks)
|
||||
}
|
||||
|
||||
func build_notifications() -> [NotificationItem] {
|
||||
var notifs: [NotificationItem] = []
|
||||
|
||||
for el in zaps {
|
||||
let evid = el.key
|
||||
let zapgrp = el.value
|
||||
|
||||
let notif: NotificationItem = .event_zap(evid, zapgrp)
|
||||
notifs.append(notif)
|
||||
}
|
||||
|
||||
if !profile_zaps.zaps.isEmpty {
|
||||
notifs.append(.profile_zap(profile_zaps))
|
||||
}
|
||||
|
||||
for el in reposts {
|
||||
let evid = el.key
|
||||
let evgrp = el.value
|
||||
|
||||
notifs.append(.repost(evid, evgrp))
|
||||
}
|
||||
|
||||
for el in reactions {
|
||||
let evid = el.key
|
||||
let evgrp = el.value
|
||||
|
||||
notifs.append(.reaction(evid, evgrp))
|
||||
}
|
||||
|
||||
for reply in replies {
|
||||
notifs.append(.reply(reply))
|
||||
}
|
||||
|
||||
notifs.sort { $0.last_event_at > $1.last_event_at }
|
||||
return notifs
|
||||
}
|
||||
|
||||
|
||||
private func insert_repost(_ ev: NostrEvent, cache: EventCache) -> Bool {
|
||||
guard let reposted_ev = ev.get_inner_event(cache: cache) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let id = reposted_ev.id
|
||||
|
||||
if let evgrp = self.reposts[id] {
|
||||
return evgrp.insert(ev)
|
||||
} else {
|
||||
let evgrp = EventGroup()
|
||||
self.reposts[id] = evgrp
|
||||
return evgrp.insert(ev)
|
||||
}
|
||||
}
|
||||
|
||||
private func insert_text(_ ev: NostrEvent) -> Bool {
|
||||
guard !has_reply.contains(ev.id) else {
|
||||
return false
|
||||
}
|
||||
|
||||
has_reply.insert(ev.id)
|
||||
replies.append(ev)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func insert_reaction(_ ev: NostrEvent) -> Bool {
|
||||
guard let ref_id = ev.referenced_ids.last else {
|
||||
return false
|
||||
}
|
||||
|
||||
let id = ref_id.id
|
||||
|
||||
if let evgrp = self.reactions[id] {
|
||||
return evgrp.insert(ev)
|
||||
} else {
|
||||
let evgrp = EventGroup()
|
||||
self.reactions[id] = evgrp
|
||||
return evgrp.insert(ev)
|
||||
}
|
||||
}
|
||||
|
||||
private func insert_event_immediate(_ ev: NostrEvent, cache: EventCache) -> Bool {
|
||||
if ev.known_kind == .boost {
|
||||
return insert_repost(ev, cache: cache)
|
||||
} else if ev.known_kind == .like {
|
||||
return insert_reaction(ev)
|
||||
} else if ev.known_kind == .text {
|
||||
return insert_text(ev)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func insert_zap_immediate(_ zap: Zap) -> Bool {
|
||||
switch zap.target {
|
||||
case .note(let notezt):
|
||||
let id = notezt.note_id
|
||||
if let zapgrp = self.zaps[notezt.note_id] {
|
||||
return zapgrp.insert(zap)
|
||||
} else {
|
||||
let zapgrp = ZapGroup()
|
||||
self.zaps[id] = zapgrp
|
||||
return zapgrp.insert(zap)
|
||||
}
|
||||
|
||||
case .profile:
|
||||
return profile_zaps.insert(zap)
|
||||
}
|
||||
}
|
||||
|
||||
func insert_event(_ ev: NostrEvent, damus_state: DamusState) -> Bool {
|
||||
if has_ev.contains(ev.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
if should_queue {
|
||||
incoming_events.append(ev)
|
||||
has_ev.insert(ev.id)
|
||||
return true
|
||||
}
|
||||
|
||||
if insert_event_immediate(ev, cache: damus_state.events) {
|
||||
self.notifications = build_notifications()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func insert_zap(_ zap: Zap, damus_state: DamusState) -> Bool {
|
||||
if should_queue {
|
||||
return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap)
|
||||
}
|
||||
|
||||
if insert_zap_immediate(zap) {
|
||||
self.notifications = build_notifications()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func filter(_ isIncluded: (NostrEvent) -> Bool) {
|
||||
var changed = false
|
||||
var count = 0
|
||||
|
||||
count = incoming_events.count
|
||||
incoming_events = incoming_events.filter(isIncluded)
|
||||
changed = changed || incoming_events.count != count
|
||||
|
||||
count = profile_zaps.zaps.count
|
||||
profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request.ev) }
|
||||
changed = changed || profile_zaps.zaps.count != count
|
||||
|
||||
for el in reactions {
|
||||
count = el.value.events.count
|
||||
el.value.events = el.value.events.filter(isIncluded)
|
||||
changed = changed || el.value.events.count != count
|
||||
}
|
||||
|
||||
for el in reposts {
|
||||
count = el.value.events.count
|
||||
el.value.events = el.value.events.filter(isIncluded)
|
||||
changed = changed || el.value.events.count != count
|
||||
}
|
||||
|
||||
for el in zaps {
|
||||
count = el.value.zaps.count
|
||||
el.value.zaps = el.value.zaps.filter {
|
||||
isIncluded($0.request.ev)
|
||||
}
|
||||
changed = changed || el.value.zaps.count != count
|
||||
}
|
||||
|
||||
count = replies.count
|
||||
replies = replies.filter(isIncluded)
|
||||
changed = changed || replies.count != count
|
||||
|
||||
if changed {
|
||||
self.notifications = build_notifications()
|
||||
}
|
||||
}
|
||||
|
||||
func flush(_ damus_state: DamusState) -> Bool {
|
||||
var inserted = false
|
||||
|
||||
for zap in incoming_zaps {
|
||||
inserted = insert_zap_immediate(zap) || inserted
|
||||
}
|
||||
|
||||
for event in incoming_events {
|
||||
inserted = insert_event_immediate(event, cache: damus_state.events) || inserted
|
||||
}
|
||||
|
||||
if inserted {
|
||||
self.notifications = build_notifications()
|
||||
}
|
||||
|
||||
return inserted
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,17 @@ struct NostrPost {
|
||||
let kind: NostrKind
|
||||
let content: String
|
||||
let references: [ReferencedId]
|
||||
let tags: [[String]]
|
||||
|
||||
init (content: String, references: [ReferencedId]) {
|
||||
self.content = content
|
||||
self.references = references
|
||||
self.kind = .text
|
||||
}
|
||||
|
||||
init (content: String, references: [ReferencedId], kind: NostrKind) {
|
||||
init (content: String, references: [ReferencedId], kind: NostrKind = .text, tags: [[String]] = []) {
|
||||
self.content = content
|
||||
self.references = references
|
||||
self.kind = kind
|
||||
self.tags = tags
|
||||
}
|
||||
|
||||
func to_event(keypair: FullKeypair) -> NostrEvent {
|
||||
return post_to_event(post: self, privkey: keypair.privkey, pubkey: keypair.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
@Published var contacts: NostrEvent? = nil
|
||||
@Published var following: Int = 0
|
||||
@Published var relays: [String: RelayInfo]? = nil
|
||||
@Published var progress: Int = 0
|
||||
|
||||
let pubkey: String
|
||||
let damus: DamusState
|
||||
|
||||
|
||||
var seen_event: Set<String> = Set()
|
||||
var sub_id = UUID().description
|
||||
var prof_subid = UUID().description
|
||||
@@ -117,7 +119,7 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
} else if ev.known_kind == .contacts {
|
||||
handle_profile_contact_event(ev)
|
||||
} else if ev.known_kind == .metadata {
|
||||
process_metadata_event(our_pubkey: damus.pubkey, profiles: damus.profiles, ev: ev)
|
||||
process_metadata_event(events: damus.events, our_pubkey: damus.pubkey, profiles: damus.profiles, ev: ev)
|
||||
}
|
||||
seen_event.insert(ev.id)
|
||||
}
|
||||
@@ -127,15 +129,21 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
case .ws_event:
|
||||
return
|
||||
case .nostr_event(let resp):
|
||||
guard resp.subid == self.sub_id || resp.subid == self.prof_subid else {
|
||||
return
|
||||
}
|
||||
switch resp {
|
||||
case .event(let sid, let ev):
|
||||
if sid != self.sub_id && sid != self.prof_subid {
|
||||
return
|
||||
}
|
||||
case .ok:
|
||||
break
|
||||
case .event(_, let ev):
|
||||
add_event(ev)
|
||||
case .notice(let notice):
|
||||
notify(.notice, notice)
|
||||
case .eose:
|
||||
if resp.subid == sub_id {
|
||||
load_profiles(profiles_subid: prof_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus)
|
||||
}
|
||||
progress += 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,25 @@
|
||||
import Foundation
|
||||
|
||||
class ReplyMap {
|
||||
var replies: [String: String] = [:]
|
||||
var replies: [String: Set<String>] = [:]
|
||||
|
||||
func lookup(_ id: String) -> String? {
|
||||
func lookup(_ id: String) -> Set<String>? {
|
||||
return replies[id]
|
||||
}
|
||||
func add(id: String, reply_id: String) {
|
||||
replies[id] = reply_id
|
||||
|
||||
private func ensure_set(id: String) {
|
||||
if replies[id] == nil {
|
||||
replies[id] = Set()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func add(id: String, reply_id: String) -> Bool {
|
||||
ensure_set(id: id)
|
||||
if (replies[id]!).contains(reply_id) {
|
||||
return false
|
||||
}
|
||||
replies[id]!.insert(reply_id)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
12
damus/Models/Search/SearchResultsModel.swift
Normal file
12
damus/Models/Search/SearchResultsModel.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// SearchResultsModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-03-03.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
class SearchResultsModel: ObservableObject {
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class SearchHomeModel: ObservableObject {
|
||||
}
|
||||
|
||||
func get_base_filter() -> NostrFilter {
|
||||
var filter = NostrFilter.filter_kinds([1, 42])
|
||||
var filter = NostrFilter.filter_kinds([NostrKind.text.rawValue, NostrKind.chat.rawValue])
|
||||
filter.limit = self.limit
|
||||
filter.until = Int64(Date.now.timeIntervalSince1970)
|
||||
return filter
|
||||
@@ -68,6 +68,8 @@ class SearchHomeModel: ObservableObject {
|
||||
}
|
||||
case .notice(let msg):
|
||||
print("search home notice: \(msg)")
|
||||
case .ok:
|
||||
break
|
||||
case .eose(let sub_id):
|
||||
loading = false
|
||||
|
||||
@@ -76,7 +78,7 @@ class SearchHomeModel: ObservableObject {
|
||||
// global events are not realtime
|
||||
unsubscribe(to: relay_id)
|
||||
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, events: events.all_events, damus_state: damus_state)
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.all_events), damus_state: damus_state)
|
||||
}
|
||||
|
||||
|
||||
@@ -98,24 +100,55 @@ func find_profiles_to_fetch_pk(profiles: Profiles, event_pubkeys: [String]) -> [
|
||||
|
||||
return Array(pubkeys)
|
||||
}
|
||||
|
||||
func find_profiles_to_fetch(profiles: Profiles, events: [NostrEvent]) -> [String] {
|
||||
|
||||
func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: EventCache) -> [String] {
|
||||
switch load {
|
||||
case .from_events(let events):
|
||||
return find_profiles_to_fetch_from_events(profiles: profiles, events: events, cache: cache)
|
||||
case .from_keys(let pks):
|
||||
return find_profiles_to_fetch_from_keys(profiles: profiles, pks: pks)
|
||||
}
|
||||
}
|
||||
|
||||
func find_profiles_to_fetch_from_keys(profiles: Profiles, pks: [String]) -> [String] {
|
||||
var pubkeys = Set<String>()
|
||||
|
||||
for ev in events {
|
||||
if profiles.lookup(id: ev.pubkey) != nil {
|
||||
for pk in pks {
|
||||
if profiles.lookup(id: pk) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pubkeys.insert(ev.pubkey)
|
||||
pubkeys.insert(pk)
|
||||
}
|
||||
|
||||
return Array(pubkeys)
|
||||
}
|
||||
|
||||
func load_profiles(profiles_subid: String, relay_id: String, events: [NostrEvent], damus_state: DamusState) {
|
||||
func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent], cache: EventCache) -> [String] {
|
||||
var pubkeys = Set<String>()
|
||||
|
||||
for ev in events {
|
||||
// lookup profiles from boosted events
|
||||
if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), profiles.lookup(id: bev.pubkey) == nil {
|
||||
pubkeys.insert(bev.pubkey)
|
||||
}
|
||||
|
||||
if profiles.lookup(id: ev.pubkey) == nil {
|
||||
pubkeys.insert(ev.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
return Array(pubkeys)
|
||||
}
|
||||
|
||||
enum PubkeysToLoad {
|
||||
case from_events([NostrEvent])
|
||||
case from_keys([String])
|
||||
}
|
||||
|
||||
func load_profiles(profiles_subid: String, relay_id: String, load: PubkeysToLoad, damus_state: DamusState) {
|
||||
var filter = NostrFilter.filter_profiles
|
||||
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, events: events)
|
||||
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events)
|
||||
filter.authors = authors
|
||||
|
||||
guard !authors.isEmpty else {
|
||||
@@ -131,7 +164,7 @@ func load_profiles(profiles_subid: String, relay_id: String, events: [NostrEvent
|
||||
}
|
||||
|
||||
if ev.known_kind == .metadata {
|
||||
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,42 +9,41 @@ import Foundation
|
||||
|
||||
|
||||
class SearchModel: ObservableObject {
|
||||
let state: DamusState
|
||||
var events: EventHolder = EventHolder()
|
||||
@Published var loading: Bool = false
|
||||
@Published var channel_name: String? = nil
|
||||
|
||||
let pool: RelayPool
|
||||
var search: NostrFilter
|
||||
let contacts: Contacts
|
||||
let sub_id = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
|
||||
init(contacts: Contacts, pool: RelayPool, search: NostrFilter) {
|
||||
self.contacts = contacts
|
||||
self.pool = pool
|
||||
init(state: DamusState, search: NostrFilter) {
|
||||
self.state = state
|
||||
self.search = search
|
||||
}
|
||||
|
||||
func filter_muted() {
|
||||
self.events.filter { should_show_event(contacts: contacts, ev: $0) }
|
||||
self.events.filter { should_show_event(contacts: state.contacts, ev: $0) }
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
// since 1 month
|
||||
search.limit = self.limit
|
||||
search.kinds = [1,5,7]
|
||||
search.kinds = [NostrKind.text.rawValue, NostrKind.like.rawValue]
|
||||
|
||||
//likes_filter.ids = ref_events.referenced_ids!
|
||||
|
||||
print("subscribing to search '\(search)' with sub_id \(sub_id)")
|
||||
pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
state.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
loading = true
|
||||
pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||
state.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
self.pool.unsubscribe(sub_id: sub_id)
|
||||
state.pool.unsubscribe(sub_id: sub_id)
|
||||
loading = false
|
||||
print("unsubscribing from search '\(search)' with sub_id \(sub_id)")
|
||||
}
|
||||
@@ -54,7 +53,7 @@ class SearchModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard should_show_event(contacts: contacts, ev: ev) else {
|
||||
guard should_show_event(contacts: state.contacts, ev: ev) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,7 +73,7 @@ class SearchModel: ObservableObject {
|
||||
}
|
||||
|
||||
func handle_event(relay_id: String, ev: NostrConnectionEvent) {
|
||||
let (_, done) = handle_subid_event(pool: pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
let (sub_id, done) = handle_subid_event(pool: state.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
if ev.is_textlike && ev.should_show_event {
|
||||
self.add_event(ev)
|
||||
} else if ev.known_kind == .channel_create {
|
||||
@@ -84,8 +83,14 @@ class SearchModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
if done {
|
||||
loading = false
|
||||
guard done else {
|
||||
return
|
||||
}
|
||||
|
||||
self.loading = false
|
||||
|
||||
if sub_id == self.sub_id {
|
||||
load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +136,9 @@ func handle_subid_event(pool: RelayPool, relay_id: String, ev: NostrConnectionEv
|
||||
case .event(let ev_subid, let ev):
|
||||
handle(ev_subid, ev)
|
||||
return (ev_subid, false)
|
||||
|
||||
case .ok:
|
||||
return (nil, false)
|
||||
|
||||
case .notice(let note):
|
||||
if note.contains("Too many subscription filters") {
|
||||
|
||||
@@ -30,184 +30,126 @@ enum InitialEvent {
|
||||
|
||||
/// manages the lifetime of a thread
|
||||
class ThreadModel: ObservableObject {
|
||||
@Published var initial_event: InitialEvent
|
||||
@Published var events: [NostrEvent] = []
|
||||
@Published var event_map: [String: Int] = [:]
|
||||
@Published var event: NostrEvent
|
||||
var event_map: Set<NostrEvent>
|
||||
|
||||
@Published var loading: Bool = false
|
||||
|
||||
var replies: ReplyMap = ReplyMap()
|
||||
|
||||
var event: NostrEvent? {
|
||||
switch initial_event {
|
||||
case .event(let ev):
|
||||
return ev
|
||||
case .event_id(let evid):
|
||||
for event in events {
|
||||
if event.id == evid {
|
||||
return event
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
init(event: NostrEvent, damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
self.event_map = Set()
|
||||
self.event = event
|
||||
add_event(event, privkey: nil)
|
||||
}
|
||||
|
||||
let damus_state: DamusState
|
||||
|
||||
let profiles_subid = UUID().description
|
||||
var base_subid = UUID().description
|
||||
|
||||
init(evid: String, damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
self.initial_event = .event_id(evid)
|
||||
}
|
||||
let base_subid = UUID().description
|
||||
let meta_subid = UUID().description
|
||||
|
||||
init(event: NostrEvent, damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
self.initial_event = .event(event)
|
||||
var subids: [String] {
|
||||
return [profiles_subid, base_subid, meta_subid]
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
self.damus_state.pool.remove_handler(sub_id: base_subid)
|
||||
self.damus_state.pool.remove_handler(sub_id: meta_subid)
|
||||
self.damus_state.pool.remove_handler(sub_id: profiles_subid)
|
||||
self.damus_state.pool.unsubscribe(sub_id: base_subid)
|
||||
print("unsubscribing from thread \(initial_event.id) with sub_id \(base_subid)")
|
||||
self.damus_state.pool.unsubscribe(sub_id: meta_subid)
|
||||
self.damus_state.pool.unsubscribe(sub_id: profiles_subid)
|
||||
print("unsubscribing from thread \(event.id) with sub_id \(base_subid)")
|
||||
}
|
||||
|
||||
func reset_events() {
|
||||
self.events.removeAll()
|
||||
self.event_map.removeAll()
|
||||
self.replies.replies.removeAll()
|
||||
}
|
||||
|
||||
func should_resubscribe(_ ev_b: NostrEvent) -> Bool {
|
||||
if self.events.count == 0 {
|
||||
return true
|
||||
}
|
||||
@discardableResult
|
||||
func set_active_event(_ ev: NostrEvent, privkey: String?) -> Bool {
|
||||
self.event = ev
|
||||
add_event(ev, privkey: privkey)
|
||||
|
||||
if ev_b.is_root_event() {
|
||||
return false
|
||||
}
|
||||
|
||||
// rough heuristic to save us from resubscribing all the time
|
||||
//return ev_b.count_ids() != self.event.count_ids()
|
||||
return true
|
||||
}
|
||||
|
||||
func set_active_event(_ ev: NostrEvent, privkey: String?) {
|
||||
if should_resubscribe(ev) {
|
||||
unsubscribe()
|
||||
self.initial_event = .event(ev)
|
||||
subscribe()
|
||||
} else {
|
||||
self.initial_event = .event(ev)
|
||||
if events.count == 0 {
|
||||
add_event(ev, privkey: privkey)
|
||||
}
|
||||
}
|
||||
//self.objectWillChange.send()
|
||||
return false
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
var meta_events = NostrFilter()
|
||||
var event_filter = NostrFilter()
|
||||
var ref_events = NostrFilter()
|
||||
var events_filter = NostrFilter()
|
||||
//var likes_filter = NostrFilter.filter_kinds(7])
|
||||
|
||||
// TODO: add referenced relays
|
||||
switch self.initial_event {
|
||||
case .event(let ev):
|
||||
ref_events.referenced_ids = ev.referenced_ids.map { $0.ref_id }
|
||||
ref_events.referenced_ids?.append(ev.id)
|
||||
ref_events.limit = 50
|
||||
events_filter.ids = ref_events.referenced_ids ?? []
|
||||
events_filter.limit = 100
|
||||
events_filter.ids?.append(ev.id)
|
||||
case .event_id(let evid):
|
||||
ref_events.referenced_ids = [evid]
|
||||
ref_events.limit = 50
|
||||
events_filter.ids = [evid]
|
||||
events_filter.limit = 100
|
||||
let thread_id = event.thread_id(privkey: nil)
|
||||
|
||||
ref_events.referenced_ids = [thread_id, event.id]
|
||||
ref_events.kinds = [NostrKind.text.rawValue]
|
||||
ref_events.limit = 1000
|
||||
|
||||
event_filter.ids = [thread_id, event.id]
|
||||
|
||||
meta_events.referenced_ids = [event.id]
|
||||
|
||||
var kinds = [NostrKind.zap.rawValue, NostrKind.text.rawValue, NostrKind.boost.rawValue]
|
||||
if !damus_state.settings.onlyzaps_mode {
|
||||
kinds.append(NostrKind.like.rawValue)
|
||||
}
|
||||
meta_events.kinds = kinds
|
||||
|
||||
//likes_filter.ids = ref_events.referenced_ids!
|
||||
meta_events.limit = 1000
|
||||
|
||||
/*
|
||||
if let last_ev = self.events.last {
|
||||
if last_ev.created_at <= Int64(Date().timeIntervalSince1970) {
|
||||
ref_events.since = last_ev.created_at
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
let base_filters = [event_filter, ref_events]
|
||||
let meta_filters = [meta_events]
|
||||
|
||||
print("subscribing to thread \(initial_event.id) with sub_id \(base_subid)")
|
||||
damus_state.pool.register_handler(sub_id: base_subid, handler: handle_event)
|
||||
print("subscribing to thread \(event.id) with sub_id \(base_subid)")
|
||||
loading = true
|
||||
damus_state.pool.send(.subscribe(.init(filters: [ref_events, events_filter], sub_id: base_subid)))
|
||||
}
|
||||
|
||||
func lookup(_ event_id: String) -> NostrEvent? {
|
||||
if let i = event_map[event_id] {
|
||||
return events[i]
|
||||
}
|
||||
return nil
|
||||
damus_state.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
|
||||
damus_state.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
|
||||
}
|
||||
|
||||
func add_event(_ ev: NostrEvent, privkey: String?) {
|
||||
guard ev.should_show_event else {
|
||||
if event_map.contains(ev) {
|
||||
return
|
||||
}
|
||||
|
||||
if event_map[ev.id] != nil {
|
||||
return
|
||||
}
|
||||
let the_ev = damus_state.events.upsert(ev)
|
||||
damus_state.replies.count_replies(the_ev)
|
||||
damus_state.events.add_replies(ev: the_ev)
|
||||
|
||||
for reply in ev.direct_replies(privkey) {
|
||||
self.replies.add(id: ev.id, reply_id: reply.ref_id)
|
||||
}
|
||||
|
||||
if insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { $0.created_at < $1.created_at }) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
//self.events.append(ev)
|
||||
//self.events = self.events.sorted { $0.created_at < $1.created_at }
|
||||
|
||||
var i: Int = 0
|
||||
for ev in events {
|
||||
self.event_map[ev.id] = i
|
||||
i += 1
|
||||
}
|
||||
|
||||
if let evid = self.initial_event.is_event_id {
|
||||
if ev.id == evid {
|
||||
// this should trigger a resubscribe...
|
||||
set_active_event(ev, privkey: privkey)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func handle_channel_meta(_ ev: NostrEvent) {
|
||||
guard let meta: ChatroomMetadata = decode_json(ev.content) else {
|
||||
return
|
||||
}
|
||||
|
||||
notify(.chatroom_meta, meta)
|
||||
event_map.insert(ev)
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func handle_event(relay_id: String, ev: NostrConnectionEvent) {
|
||||
|
||||
let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in
|
||||
guard sid == base_subid || sid == profiles_subid else {
|
||||
guard subids.contains(sid) else {
|
||||
return
|
||||
}
|
||||
|
||||
if ev.known_kind == .metadata {
|
||||
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
|
||||
} else if ev.is_textlike {
|
||||
self.add_event(ev, privkey: self.damus_state.keypair.privkey)
|
||||
} else if ev.known_kind == .channel_meta || ev.known_kind == .channel_create {
|
||||
handle_channel_meta(ev)
|
||||
}
|
||||
}
|
||||
|
||||
guard done && (sub_id == base_subid || sub_id == profiles_subid) else {
|
||||
guard done, let sub_id, subids.contains(sub_id) else {
|
||||
return
|
||||
}
|
||||
|
||||
if (events.contains { ev in ev.id == initial_event.id }) {
|
||||
if event_map.contains(event) {
|
||||
loading = false
|
||||
}
|
||||
|
||||
if sub_id == self.base_subid {
|
||||
load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, events: events, damus_state: damus_state)
|
||||
load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map)), damus_state: damus_state)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,19 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum TranslationService: String, CaseIterable, Identifiable {
|
||||
enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
|
||||
init?(from string: String) {
|
||||
guard let ts = TranslationService(rawValue: string) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self = ts
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
struct Model: Identifiable, Hashable {
|
||||
@@ -19,15 +31,18 @@ enum TranslationService: String, CaseIterable, Identifiable {
|
||||
case none
|
||||
case libretranslate
|
||||
case deepl
|
||||
case nokyctranslate
|
||||
|
||||
var model: Model {
|
||||
switch self {
|
||||
case .none:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("None", comment: "Dropdown option for selecting no translation service."))
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("none_translation_service", value: "None", comment: "Dropdown option for selecting no translation service."))
|
||||
case .libretranslate:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("LibreTranslate (Open Source)", comment: "Dropdown option for selecting LibreTranslate as the translation service."))
|
||||
case .deepl:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("DeepL (Proprietary, Higher Accuracy)", comment: "Dropdown option for selecting DeepL as the translation service."))
|
||||
case .nokyctranslate:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("NoKYCTranslate.com (Prepay with BTC)", comment: "Dropdown option for selecting NoKYCTranslate.com as the translation service."))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,108 +7,159 @@
|
||||
|
||||
import Foundation
|
||||
import Vault
|
||||
import UIKit
|
||||
|
||||
func should_show_wallet_selector(_ pubkey: String) -> Bool {
|
||||
return UserDefaults.standard.object(forKey: "show_wallet_selector") as? Bool ?? true
|
||||
}
|
||||
let fallback_zap_amount = 1000
|
||||
|
||||
func pk_setting_key(_ pubkey: String, key: String) -> String {
|
||||
return "\(pubkey)_\(key)"
|
||||
}
|
||||
|
||||
func default_zap_setting_key(pubkey: String) -> String {
|
||||
return pk_setting_key(pubkey, key: "default_zap_amount")
|
||||
}
|
||||
|
||||
func set_default_zap_amount(pubkey: String, amount: Int) {
|
||||
let key = default_zap_setting_key(pubkey: pubkey)
|
||||
UserDefaults.standard.setValue(amount, forKey: key)
|
||||
}
|
||||
|
||||
func get_default_zap_amount(pubkey: String) -> Int? {
|
||||
let key = default_zap_setting_key(pubkey: pubkey)
|
||||
let amt = UserDefaults.standard.integer(forKey: key)
|
||||
if amt == 0 {
|
||||
return nil
|
||||
}
|
||||
return amt
|
||||
}
|
||||
|
||||
|
||||
func get_default_wallet(_ pubkey: String) -> Wallet {
|
||||
if let defaultWalletName = UserDefaults.standard.string(forKey: "default_wallet"),
|
||||
let default_wallet = Wallet(rawValue: defaultWalletName)
|
||||
{
|
||||
return default_wallet
|
||||
} else {
|
||||
return .system_default_wallet
|
||||
}
|
||||
}
|
||||
|
||||
private func get_translation_service(_ pubkey: String) -> TranslationService? {
|
||||
guard let translation_service = UserDefaults.standard.string(forKey: "translation_service") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return TranslationService(rawValue: translation_service)
|
||||
}
|
||||
|
||||
private func get_deepl_plan(_ pubkey: String) -> DeepLPlan? {
|
||||
guard let server_name = UserDefaults.standard.string(forKey: "deepl_plan") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DeepLPlan(rawValue: server_name)
|
||||
}
|
||||
|
||||
private func get_libretranslate_server(_ pubkey: String) -> LibreTranslateServer? {
|
||||
guard let server_name = UserDefaults.standard.string(forKey: "libretranslate_server") else {
|
||||
return nil
|
||||
@propertyWrapper struct Setting<T: Equatable> {
|
||||
private let key: String
|
||||
private var value: T
|
||||
|
||||
init(key: String, default_value: T) {
|
||||
self.key = pk_setting_key(UserSettingsStore.pubkey ?? "", key: key)
|
||||
if let loaded = UserDefaults.standard.object(forKey: self.key) as? T {
|
||||
self.value = loaded
|
||||
} else if let loaded = UserDefaults.standard.object(forKey: key) as? T {
|
||||
// try to load from deprecated non-pubkey-keyed setting
|
||||
self.value = loaded
|
||||
} else {
|
||||
self.value = default_value
|
||||
}
|
||||
}
|
||||
|
||||
return LibreTranslateServer(rawValue: server_name)
|
||||
var wrappedValue: T {
|
||||
get { return value }
|
||||
set {
|
||||
guard self.value != newValue else {
|
||||
return
|
||||
}
|
||||
self.value = newValue
|
||||
UserDefaults.standard.set(newValue, forKey: key)
|
||||
UserSettingsStore.shared!.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func get_libretranslate_url(_ pubkey: String, server: LibreTranslateServer) -> String? {
|
||||
if let url = server.model.url {
|
||||
return url
|
||||
@propertyWrapper class StringSetting<T: StringCodable & Equatable> {
|
||||
private let key: String
|
||||
private var value: T
|
||||
|
||||
init(key: String, default_value: T) {
|
||||
self.key = pk_setting_key(UserSettingsStore.pubkey ?? "", key: key)
|
||||
if let loaded = UserDefaults.standard.string(forKey: self.key), let val = T.init(from: loaded) {
|
||||
self.value = val
|
||||
} else if let loaded = UserDefaults.standard.string(forKey: key), let val = T.init(from: loaded) {
|
||||
// try to load from deprecated non-pubkey-keyed setting
|
||||
self.value = val
|
||||
} else {
|
||||
self.value = default_value
|
||||
}
|
||||
}
|
||||
|
||||
return UserDefaults.standard.object(forKey: "libretranslate_url") as? String
|
||||
var wrappedValue: T {
|
||||
get { return value }
|
||||
set {
|
||||
guard self.value != newValue else {
|
||||
return
|
||||
}
|
||||
self.value = newValue
|
||||
UserDefaults.standard.set(newValue.to_string(), forKey: key)
|
||||
UserSettingsStore.shared!.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UserSettingsStore: ObservableObject {
|
||||
@Published var default_wallet: Wallet {
|
||||
didSet {
|
||||
UserDefaults.standard.set(default_wallet.rawValue, forKey: "default_wallet")
|
||||
static var pubkey: String? = nil
|
||||
static var shared: UserSettingsStore? = nil
|
||||
|
||||
@StringSetting(key: "default_wallet", default_value: .system_default_wallet)
|
||||
var default_wallet: Wallet
|
||||
|
||||
@StringSetting(key: "default_media_uploader", default_value: .nostrBuild)
|
||||
var default_media_uploader: MediaUploader
|
||||
|
||||
@Setting(key: "show_wallet_selector", default_value: true)
|
||||
var show_wallet_selector: Bool
|
||||
|
||||
@Setting(key: "left_handed", default_value: false)
|
||||
var left_handed: Bool
|
||||
|
||||
@Setting(key: "always_show_images", default_value: false)
|
||||
var always_show_images: Bool
|
||||
|
||||
@Setting(key: "zap_vibration", default_value: true)
|
||||
var zap_vibration: Bool
|
||||
|
||||
@Setting(key: "zap_notification", default_value: true)
|
||||
var zap_notification: Bool
|
||||
|
||||
@Setting(key: "default_zap_amount", default_value: fallback_zap_amount)
|
||||
var default_zap_amount: Int
|
||||
|
||||
@Setting(key: "mention_notification", default_value: true)
|
||||
var mention_notification: Bool
|
||||
|
||||
@StringSetting(key: "zap_type", default_value: ZapType.pub)
|
||||
var default_zap_type: ZapType
|
||||
|
||||
@Setting(key: "repost_notification", default_value: true)
|
||||
var repost_notification: Bool
|
||||
|
||||
@Setting(key: "dm_notification", default_value: true)
|
||||
var dm_notification: Bool
|
||||
|
||||
@Setting(key: "like_notification", default_value: true)
|
||||
var like_notification: Bool
|
||||
|
||||
@Setting(key: "notification_only_from_following", default_value: false)
|
||||
var notification_only_from_following: Bool
|
||||
|
||||
@Setting(key: "translate_dms", default_value: false)
|
||||
var translate_dms: Bool
|
||||
|
||||
@Setting(key: "truncate_timeline_text", default_value: false)
|
||||
var truncate_timeline_text: Bool
|
||||
|
||||
@Setting(key: "truncate_mention_text", default_value: true)
|
||||
var truncate_mention_text: Bool
|
||||
|
||||
@Setting(key: "notification_indicators", default_value: NewEventsBits.all.rawValue)
|
||||
var notification_indicators: Int
|
||||
|
||||
@Setting(key: "auto_translate", default_value: true)
|
||||
var auto_translate: Bool
|
||||
|
||||
@Setting(key: "show_only_preferred_languages", default_value: false)
|
||||
var show_only_preferred_languages: Bool
|
||||
|
||||
@Setting(key: "onlyzaps_mode", default_value: false)
|
||||
var onlyzaps_mode: Bool
|
||||
|
||||
@Setting(key: "disable_animation", default_value: UIAccessibility.isReduceMotionEnabled)
|
||||
var disable_animation: Bool
|
||||
|
||||
// Helper for inverse of disable_animation.
|
||||
// disable_animation was introduced as a setting first, but it's more natural for the settings UI to show the inverse.
|
||||
var enable_animation: Bool {
|
||||
get {
|
||||
!disable_animation
|
||||
}
|
||||
set {
|
||||
disable_animation = !newValue
|
||||
}
|
||||
}
|
||||
|
||||
@Published var show_wallet_selector: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(show_wallet_selector, forKey: "show_wallet_selector")
|
||||
}
|
||||
}
|
||||
@StringSetting(key: "friend_filter", default_value: .all)
|
||||
var friend_filter: FriendFilter
|
||||
|
||||
@Published var left_handed: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(left_handed, forKey: "left_handed")
|
||||
}
|
||||
}
|
||||
@StringSetting(key: "translation_service", default_value: .none)
|
||||
var translation_service: TranslationService
|
||||
|
||||
@Published var translation_service: TranslationService {
|
||||
didSet {
|
||||
UserDefaults.standard.set(translation_service.rawValue, forKey: "translation_service")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var deepl_plan: DeepLPlan {
|
||||
didSet {
|
||||
UserDefaults.standard.set(deepl_plan.rawValue, forKey: "deepl_plan")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var deepl_api_key: String {
|
||||
@StringSetting(key: "deepl_plan", default_value: .free)
|
||||
var deepl_plan: DeepLPlan
|
||||
|
||||
var deepl_api_key: String {
|
||||
didSet {
|
||||
do {
|
||||
if deepl_api_key == "" {
|
||||
@@ -122,31 +173,14 @@ class UserSettingsStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@Published var libretranslate_server: LibreTranslateServer {
|
||||
didSet {
|
||||
if oldValue == libretranslate_server {
|
||||
return
|
||||
}
|
||||
@StringSetting(key: "libretranslate_server", default_value: .terraprint)
|
||||
var libretranslate_server: LibreTranslateServer
|
||||
|
||||
@Setting(key: "libretranslate_url", default_value: "")
|
||||
var libretranslate_url: String
|
||||
|
||||
UserDefaults.standard.set(libretranslate_server.rawValue, forKey: "libretranslate_server")
|
||||
|
||||
libretranslate_api_key = ""
|
||||
|
||||
if libretranslate_server == .custom {
|
||||
libretranslate_url = ""
|
||||
} else {
|
||||
libretranslate_url = libretranslate_server.model.url!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var libretranslate_url: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(libretranslate_url, forKey: "libretranslate_url")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var libretranslate_api_key: String {
|
||||
@Setting(key: "libretranslate_api_key", default_value: "")
|
||||
var libretranslate_api_key: String {
|
||||
didSet {
|
||||
do {
|
||||
if libretranslate_api_key == "" {
|
||||
@@ -159,53 +193,34 @@ class UserSettingsStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var nokyctranslate_api_key: String {
|
||||
didSet {
|
||||
do {
|
||||
if nokyctranslate_api_key == "" {
|
||||
try clearNoKYCTranslateApiKey()
|
||||
} else {
|
||||
try saveNoKYCTranslateApiKey(nokyctranslate_api_key)
|
||||
}
|
||||
} catch {
|
||||
// No-op.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
// TODO: pubkey-scoped settings
|
||||
let pubkey = ""
|
||||
self.default_wallet = get_default_wallet(pubkey)
|
||||
show_wallet_selector = should_show_wallet_selector(pubkey)
|
||||
|
||||
left_handed = UserDefaults.standard.object(forKey: "left_handed") as? Bool ?? false
|
||||
|
||||
// Note from @tyiu:
|
||||
// Default translation service is disabled by default for now until we gain some confidence that it is working well in production.
|
||||
// Instead of throwing all Damus users onto feature immediately, allow for discovery of feature organically.
|
||||
// Also, we are connecting to servers listed as mirrors on the official LibreTranslate GitHub README that do not require API keys.
|
||||
// However, we have not asked them for permission to use, so we're trying to be good neighbors for now.
|
||||
// Opportunity: spin up dedicated trusted LibreTranslate server that requires an API key for any access (or higher rate limit access).
|
||||
if let translation_service = get_translation_service(pubkey) {
|
||||
self.translation_service = translation_service
|
||||
} else {
|
||||
self.translation_service = .none
|
||||
}
|
||||
|
||||
if let libretranslate_server = get_libretranslate_server(pubkey) {
|
||||
self.libretranslate_server = libretranslate_server
|
||||
self.libretranslate_url = get_libretranslate_url(pubkey, server: libretranslate_server) ?? ""
|
||||
} else {
|
||||
// Choose a random server to distribute load.
|
||||
libretranslate_server = .allCases.filter { $0 != .custom }.randomElement()!
|
||||
libretranslate_url = ""
|
||||
}
|
||||
|
||||
do {
|
||||
libretranslate_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration())
|
||||
} catch {
|
||||
libretranslate_api_key = ""
|
||||
}
|
||||
|
||||
if let deepl_plan = get_deepl_plan(pubkey) {
|
||||
self.deepl_plan = deepl_plan
|
||||
} else {
|
||||
self.deepl_plan = .free
|
||||
}
|
||||
|
||||
do {
|
||||
deepl_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration())
|
||||
} catch {
|
||||
deepl_api_key = ""
|
||||
}
|
||||
|
||||
do {
|
||||
nokyctranslate_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
|
||||
} catch {
|
||||
nokyctranslate_api_key = ""
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func saveLibreTranslateApiKey(_ apiKey: String) throws {
|
||||
@@ -216,6 +231,14 @@ class UserSettingsStore: ObservableObject {
|
||||
try Vault.deletePrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration())
|
||||
}
|
||||
|
||||
private func saveNoKYCTranslateApiKey(_ apiKey: String) throws {
|
||||
try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
|
||||
}
|
||||
|
||||
private func clearNoKYCTranslateApiKey() throws {
|
||||
try Vault.deletePrivateKey(keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
|
||||
}
|
||||
|
||||
private func saveDeepLApiKey(_ apiKey: String) throws {
|
||||
try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusDeepLKeychainConfiguration())
|
||||
}
|
||||
@@ -224,7 +247,7 @@ class UserSettingsStore: ObservableObject {
|
||||
try Vault.deletePrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration())
|
||||
}
|
||||
|
||||
func can_translate(_ pubkey: String) -> Bool {
|
||||
var can_translate: Bool {
|
||||
switch translation_service {
|
||||
case .none:
|
||||
return false
|
||||
@@ -232,6 +255,8 @@ class UserSettingsStore: ObservableObject {
|
||||
return URLComponents(string: libretranslate_url) != nil
|
||||
case .deepl:
|
||||
return deepl_api_key != ""
|
||||
case .nokyctranslate:
|
||||
return nokyctranslate_api_key != ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,3 +272,13 @@ struct DamusDeepLKeychainConfiguration: KeychainConfiguration {
|
||||
var accessGroup: String? = nil
|
||||
var accountName = "deepl_apikey"
|
||||
}
|
||||
|
||||
struct DamusNoKYCTranslateKeychainConfiguration: KeychainConfiguration {
|
||||
var serviceName = "damus"
|
||||
var accessGroup: String? = nil
|
||||
var accountName = "nokyctranslate_apikey"
|
||||
}
|
||||
|
||||
func pk_setting_key(_ pubkey: String, key: String) -> String {
|
||||
return "\(pubkey)_\(key)"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Wallet: String, CaseIterable, Identifiable {
|
||||
enum Wallet: String, CaseIterable, Identifiable, StringCodable {
|
||||
var id: String { self.rawValue }
|
||||
|
||||
struct Model: Identifiable, Hashable {
|
||||
@@ -20,6 +20,17 @@ enum Wallet: String, CaseIterable, Identifiable {
|
||||
var image: String
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
init?(from string: String) {
|
||||
guard let w = Wallet(rawValue: string) else {
|
||||
return nil
|
||||
}
|
||||
self = w
|
||||
}
|
||||
|
||||
// New url prefixes needed to be added to LSApplicationQueriesSchemes
|
||||
case system_default_wallet
|
||||
case strike
|
||||
@@ -42,42 +53,42 @@ enum Wallet: String, CaseIterable, Identifiable {
|
||||
return .init(index: -1, tag: "systemdefaultwallet", displayName: NSLocalizedString("Local default", comment: "Dropdown option label for system default for Lightning wallet."),
|
||||
link: "lightning:", appStoreLink: "lightning:", image: "")
|
||||
case .strike:
|
||||
return .init(index: 0, tag: "strike", displayName: NSLocalizedString("Strike", comment: "Dropdown option label for Lightning wallet, Strike."), link: "strike:",
|
||||
return .init(index: 0, tag: "strike", displayName: "Strike", link: "strike:",
|
||||
appStoreLink: "https://apps.apple.com/us/app/strike-bitcoin-payments/id1488724463", image: "strike")
|
||||
case .cashapp:
|
||||
return .init(index: 1, tag: "cashapp", displayName: NSLocalizedString("Cash App", comment: "Dropdown option label for Lightning wallet, Cash App."), link: "https://cash.app/launch/lightning/",
|
||||
return .init(index: 1, tag: "cashapp", displayName: "Cash App", link: "https://cash.app/launch/lightning/",
|
||||
appStoreLink: "https://apps.apple.com/us/app/cash-app/id711923939", image: "cashapp")
|
||||
case .muun:
|
||||
return .init(index: 2, tag: "muun", displayName: NSLocalizedString("Muun", comment: "Dropdown option label for Lightning wallet, Muun."), link: "muun:", appStoreLink: "https://apps.apple.com/us/app/muun-wallet/id1482037683", image: "muun")
|
||||
return .init(index: 2, tag: "muun", displayName: "Muun", link: "muun:", appStoreLink: "https://apps.apple.com/us/app/muun-wallet/id1482037683", image: "muun")
|
||||
case .bluewallet:
|
||||
return .init(index: 3, tag: "bluewallet", displayName: NSLocalizedString("Blue Wallet", comment: "Dropdown option label for Lightning wallet, Blue Wallet."), link: "bluewallet:lightning:",
|
||||
return .init(index: 3, tag: "bluewallet", displayName: "Blue Wallet", link: "bluewallet:lightning:",
|
||||
appStoreLink: "https://apps.apple.com/us/app/bluewallet-bitcoin-wallet/id1376878040", image: "bluewallet")
|
||||
case .walletofsatoshi:
|
||||
return .init(index: 4, tag: "walletofsatoshi", displayName: NSLocalizedString("Wallet of Satoshi", comment: "Dropdown option label for Lightning wallet, Wallet of Satoshi."), link: "walletofsatoshi:lightning:",
|
||||
return .init(index: 4, tag: "walletofsatoshi", displayName: "Wallet of Satoshi", link: "walletofsatoshi:lightning:",
|
||||
appStoreLink: "https://apps.apple.com/us/app/wallet-of-satoshi/id1438599608", image: "walletofsatoshi")
|
||||
case .zebedee:
|
||||
return .init(index: 5, tag: "zebedee", displayName: NSLocalizedString("Zebedee", comment: "Dropdown option label for Lightning wallet, Zebedee."), link: "zebedee:lightning:",
|
||||
return .init(index: 5, tag: "zebedee", displayName: "Zebedee", link: "zebedee:lightning:",
|
||||
appStoreLink: "https://apps.apple.com/us/app/zebedee-wallet/id1484394401", image: "zebedee")
|
||||
case .zeusln:
|
||||
return .init(index: 6, tag: "zeusln", displayName: NSLocalizedString("Zeus LN", comment: "Dropdown option label for Lightning wallet, Zeus LN."), link: "zeusln:lightning:",
|
||||
return .init(index: 6, tag: "zeusln", displayName: "Zeus LN", link: "zeusln:lightning:",
|
||||
appStoreLink: "https://apps.apple.com/us/app/zeus-ln/id1456038895", image: "zeusln")
|
||||
case .lnlink:
|
||||
return .init(index: 7, tag: "lnlink", displayName: NSLocalizedString("LNLink", comment: "Dropdown option label for Lightning wallet, LNLink."), link: "lnlink:lightning:",
|
||||
return .init(index: 7, tag: "lnlink", displayName: "LNLink", link: "lnlink:lightning:",
|
||||
appStoreLink: "https://testflight.apple.com/join/aNY4yuuZ", image: "lnlink")
|
||||
case .phoenix:
|
||||
return .init(index: 8, tag: "phoenix", displayName: NSLocalizedString("Phoenix", comment: "Dropdown option label for Lightning wallet, Phoenix."), link: "phoenix://",
|
||||
return .init(index: 8, tag: "phoenix", displayName: "Phoenix", link: "phoenix://",
|
||||
appStoreLink: "https://apps.apple.com/us/app/phoenix-wallet/id1544097028", image: "phoenix")
|
||||
case .breez:
|
||||
return .init(index: 9, tag: "breez", displayName: NSLocalizedString("Breez", comment: "Dropdown option label for Lightning wallet, Breez."), link: "breez:",
|
||||
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:",
|
||||
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
|
||||
case .bitcoinbeach:
|
||||
return .init(index: 10, tag: "bitcoinbeach", displayName: NSLocalizedString("Bitcoin Beach", comment: "Dropdown option label for Lightning wallet, Bitcoin Beach."), link: "bitcoinbeach://",
|
||||
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://",
|
||||
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
|
||||
case .blixtwallet:
|
||||
return .init(index: 11, tag: "blixtwallet", displayName: NSLocalizedString("Blixt Wallet", comment: "Dropdown option label for Lightning wallet, Blixt Wallet"), link: "blixtwallet:lightning:",
|
||||
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
|
||||
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
|
||||
case .river:
|
||||
return .init(index: 12, tag: "river", displayName: NSLocalizedString("River", comment: "Dropdown option label for Lightning wallet, River"), link: "river://",
|
||||
return .init(index: 12, tag: "river", displayName: "River", link: "river://",
|
||||
appStoreLink: "https://apps.apple.com/us/app/river-buy-mine-bitcoin/id1536176542", image: "river")
|
||||
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import Foundation
|
||||
class ZapsModel: ObservableObject {
|
||||
let state: DamusState
|
||||
let target: ZapTarget
|
||||
var zaps: [Zap]
|
||||
|
||||
let zaps_subid = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
@@ -18,11 +17,14 @@ class ZapsModel: ObservableObject {
|
||||
init(state: DamusState, target: ZapTarget) {
|
||||
self.state = state
|
||||
self.target = target
|
||||
self.zaps = []
|
||||
}
|
||||
|
||||
var zaps: [Zap] {
|
||||
return state.events.lookup_zaps(target: target)
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
var filter = NostrFilter.filter_kinds([9735])
|
||||
var filter = NostrFilter.filter_kinds([NostrKind.zap.rawValue])
|
||||
switch target {
|
||||
case .profile(let profile_id):
|
||||
filter.pubkeys = [profile_id]
|
||||
@@ -46,18 +48,20 @@ class ZapsModel: ObservableObject {
|
||||
}
|
||||
|
||||
switch resp {
|
||||
case .ok:
|
||||
break
|
||||
case .notice:
|
||||
break
|
||||
case .eose:
|
||||
let events = self.zaps.map { $0.request.ev }
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, events: events, damus_state: state)
|
||||
let events = state.events.lookup_zaps(target: target).map { $0.request_ev }
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state)
|
||||
case .event(_, let ev):
|
||||
guard ev.kind == 9735 else {
|
||||
return
|
||||
}
|
||||
|
||||
if let zap = state.zaps.zaps[ev.id] {
|
||||
if insert_uniq_sorted_zap(zaps: &zaps, new_zap: zap) {
|
||||
if state.events.store_zap(zap: zap) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
} else {
|
||||
@@ -65,13 +69,11 @@ class ZapsModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper) else {
|
||||
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: state.keypair.privkey) else {
|
||||
return
|
||||
}
|
||||
|
||||
state.zaps.add_zap(zap: zap)
|
||||
|
||||
if insert_uniq_sorted_zap(zaps: &zaps, new_zap: zap) {
|
||||
if self.state.add_zap(zap: zap) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Profile: Codable {
|
||||
class Profile: Codable {
|
||||
var value: [String: AnyCodable]
|
||||
|
||||
init (name: String?, display_name: String?, about: String?, picture: String?, banner: String?, website: String?, lud06: String?, lud16: String?, nip05: String?) {
|
||||
@@ -39,7 +39,7 @@ struct Profile: Codable {
|
||||
return s
|
||||
}
|
||||
|
||||
private mutating func set_val<T>(_ key: String, _ val: T?) {
|
||||
private func set_val<T>(_ key: String, _ val: T?) {
|
||||
if val == nil {
|
||||
self.value.removeValue(forKey: key)
|
||||
return
|
||||
@@ -48,10 +48,15 @@ struct Profile: Codable {
|
||||
self.value[key] = AnyCodable.init(val)
|
||||
}
|
||||
|
||||
private mutating func set_str(_ key: String, _ val: String?) {
|
||||
private func set_str(_ key: String, _ val: String?) {
|
||||
set_val(key, val)
|
||||
}
|
||||
|
||||
var reactions: Bool? {
|
||||
get { return get_val("reactions"); }
|
||||
set(s) { set_val("reactions", s) }
|
||||
}
|
||||
|
||||
var deleted: Bool? {
|
||||
get { return get_val("deleted"); }
|
||||
set(s) { set_val("deleted", s) }
|
||||
@@ -98,16 +103,45 @@ struct Profile: Codable {
|
||||
}
|
||||
|
||||
var website_url: URL? {
|
||||
return self.website.flatMap { URL(string: $0) }
|
||||
if self.website?.trimmingCharacters(in: .whitespacesAndNewlines) == "" {
|
||||
return nil
|
||||
}
|
||||
return self.website.flatMap { url in
|
||||
let trim = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !(trim.hasPrefix("http://") || trim.hasPrefix("https://")) {
|
||||
return URL(string: "https://" + trim)
|
||||
}
|
||||
return URL(string: trim)
|
||||
}
|
||||
}
|
||||
|
||||
func cache_lnurl() {
|
||||
guard self._lnurl == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let addr = lud16 ?? lud06 else {
|
||||
return
|
||||
}
|
||||
|
||||
self._lnurl = lnaddress_to_lnurl(addr)
|
||||
}
|
||||
|
||||
private var _lnurl: String? = nil
|
||||
var lnurl: String? {
|
||||
if let _lnurl {
|
||||
return _lnurl
|
||||
}
|
||||
|
||||
guard let addr = lud16 ?? lud06 else {
|
||||
return nil;
|
||||
}
|
||||
|
||||
if addr.contains("@") {
|
||||
return lnaddress_to_lnurl(addr);
|
||||
// this is a heavy op and is used a lot in views, cache it!
|
||||
let addr = lnaddress_to_lnurl(addr);
|
||||
self._lnurl = addr
|
||||
return addr
|
||||
}
|
||||
|
||||
if !addr.lowercased().hasPrefix("lnurl") {
|
||||
@@ -130,7 +164,7 @@ struct Profile: Codable {
|
||||
self.value = [:]
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
self.value = try container.decode([String: AnyCodable].self)
|
||||
}
|
||||
@@ -140,9 +174,8 @@ struct Profile: Codable {
|
||||
try container.encode(value)
|
||||
}
|
||||
|
||||
static func displayName(profile: Profile?, pubkey: String) -> String {
|
||||
let pk = bech32_nopre_pubkey(pubkey) ?? pubkey
|
||||
return profile?.name ?? abbrev_pubkey(pk)
|
||||
static func displayName(profile: Profile?, pubkey: String) -> DisplayName {
|
||||
return parse_display_name(profile: profile, pubkey: pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,18 @@ import CommonCrypto
|
||||
import secp256k1
|
||||
import secp256k1_implementation
|
||||
import CryptoKit
|
||||
|
||||
import NaturalLanguage
|
||||
|
||||
|
||||
enum ValidationResult: Decodable {
|
||||
case unknown
|
||||
case ok
|
||||
case bad_id
|
||||
case bad_sig
|
||||
|
||||
var is_bad: Bool {
|
||||
return self == .bad_id || self == .bad_sig
|
||||
}
|
||||
}
|
||||
|
||||
struct OtherEvent {
|
||||
@@ -37,6 +42,18 @@ struct ReferencedId: Identifiable, Hashable, Equatable {
|
||||
var id: String {
|
||||
return ref_id
|
||||
}
|
||||
|
||||
static func q(_ id: String, relay_id: String? = nil) -> ReferencedId {
|
||||
return ReferencedId(ref_id: id, relay_id: relay_id, key: "q")
|
||||
}
|
||||
|
||||
static func e(_ id: String, relay_id: String? = nil) -> ReferencedId {
|
||||
return ReferencedId(ref_id: id, relay_id: relay_id, key: "e")
|
||||
}
|
||||
|
||||
static func p(_ id: String, relay_id: String? = nil) -> ReferencedId {
|
||||
return ReferencedId(ref_id: id, relay_id: relay_id, key: "p")
|
||||
}
|
||||
}
|
||||
|
||||
struct EventId: Identifiable, CustomStringConvertible {
|
||||
@@ -81,7 +98,7 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
|
||||
}
|
||||
|
||||
var too_big: Bool {
|
||||
return self.content.count > 16000
|
||||
return self.content.utf8.count > 16000
|
||||
}
|
||||
|
||||
var should_show_event: Bool {
|
||||
@@ -92,14 +109,6 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
|
||||
return calculate_event_id(ev: self) == self.id
|
||||
}
|
||||
|
||||
var is_valid: Bool {
|
||||
return validity == .ok
|
||||
}
|
||||
|
||||
lazy var validity: ValidationResult = {
|
||||
return .ok //validate_event(ev: self)
|
||||
}()
|
||||
|
||||
private var _blocks: [Block]? = nil
|
||||
func blocks(_ privkey: String?) -> [Block] {
|
||||
if let bs = _blocks {
|
||||
@@ -114,14 +123,22 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
|
||||
return parse_mentions(content: content, tags: self.tags)
|
||||
}
|
||||
|
||||
lazy var inner_event: NostrEvent? = {
|
||||
// don't try to deserialize an inner event if we know there won't be one
|
||||
if self.known_kind == .boost {
|
||||
return event_from_json(dat: self.content)
|
||||
}
|
||||
return nil
|
||||
private lazy var inner_event: NostrEvent? = {
|
||||
return event_from_json(dat: self.content)
|
||||
}()
|
||||
|
||||
func get_inner_event(cache: EventCache) -> NostrEvent? {
|
||||
guard self.known_kind == .boost else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if self.content == "", let ref = self.referenced_ids.first {
|
||||
return cache.lookup(ref.ref_id)
|
||||
}
|
||||
|
||||
return self.inner_event
|
||||
}
|
||||
|
||||
private var _event_refs: [EventRef]? = nil
|
||||
func event_refs(_ privkey: String?) -> [EventRef] {
|
||||
if let rs = _event_refs {
|
||||
@@ -157,7 +174,7 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
|
||||
pubkey = refkey.ref_id
|
||||
}
|
||||
|
||||
let dec = decrypt_dm(key, pubkey: pubkey, content: self.content)
|
||||
let dec = decrypt_dm(key, pubkey: pubkey, content: self.content, encoding: .base64)
|
||||
self.decrypted_content = dec
|
||||
|
||||
return dec
|
||||
@@ -168,6 +185,9 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
|
||||
return decrypted(privkey: privkey) ?? "*failed to decrypt content*"
|
||||
}
|
||||
|
||||
return content
|
||||
|
||||
/*
|
||||
switch validity {
|
||||
case .ok:
|
||||
return content
|
||||
@@ -176,6 +196,7 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
|
||||
case .bad_sig:
|
||||
return content + "\n\n*WARNING: invalid signature, could be forged!*"
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
var description: String {
|
||||
@@ -211,6 +232,16 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func thread_id(privkey: String?) -> String {
|
||||
for ref in event_refs(privkey) {
|
||||
if let thread_id = ref.is_thread_id {
|
||||
return thread_id.ref_id
|
||||
}
|
||||
}
|
||||
|
||||
return self.id
|
||||
}
|
||||
|
||||
public func last_refid() -> ReferencedId? {
|
||||
var mlast: Int? = nil
|
||||
@@ -245,6 +276,25 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
|
||||
return event_is_reply(self, privkey: privkey)
|
||||
}
|
||||
|
||||
func note_language(_ privkey: String?) -> String? {
|
||||
// Rely on Apple's NLLanguageRecognizer to tell us which language it thinks the note is in
|
||||
// and filter on only the text portions of the content as URLs and hashtags confuse the language recognizer.
|
||||
let originalBlocks = blocks(privkey)
|
||||
let originalOnlyText = originalBlocks.compactMap { $0.is_text }.joined(separator: " ")
|
||||
|
||||
// Only accept language recognition hypothesis if there's at least a 50% probability that it's accurate.
|
||||
let languageRecognizer = NLLanguageRecognizer()
|
||||
languageRecognizer.processString(originalOnlyText)
|
||||
|
||||
guard let locale = languageRecognizer.languageHypotheses(withMaximum: 1).first(where: { $0.value >= 0.5 })?.key.rawValue else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove the variant component and just take the language part as translation services typically only supports the variant-less language.
|
||||
// Moreover, speakers of one variant can generally understand other variants.
|
||||
return localeToLanguage(locale)
|
||||
}
|
||||
|
||||
public var referenced_ids: [ReferencedId] {
|
||||
return get_referenced_ids(key: "e")
|
||||
}
|
||||
@@ -504,16 +554,21 @@ func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
|
||||
guard let privkey = keypair.privkey else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
|
||||
let rw_relay_info = RelayInfo(read: true, write: true)
|
||||
var relays: [String: RelayInfo] = [:]
|
||||
for relay in BOOTSTRAP_RELAYS {
|
||||
|
||||
for relay in bootstrap_relays {
|
||||
relays[relay] = rw_relay_info
|
||||
}
|
||||
|
||||
let relay_json = encode_json(relays)!
|
||||
let damus_pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681"
|
||||
let jb55_pubkey = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" // lol
|
||||
let tags = [
|
||||
["p", damus_pubkey],
|
||||
["p", jb55_pubkey],
|
||||
["p", keypair.pubkey] // you're a friend of yourself!
|
||||
]
|
||||
let ev = NostrEvent(content: relay_json,
|
||||
@@ -525,7 +580,7 @@ func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
|
||||
return ev
|
||||
}
|
||||
|
||||
func make_metadata_event(keypair: Keypair, metadata: NostrMetadata) -> NostrEvent? {
|
||||
func make_metadata_event(keypair: Keypair, metadata: Profile) -> NostrEvent? {
|
||||
guard let privkey = keypair.privkey else {
|
||||
return nil
|
||||
}
|
||||
@@ -573,22 +628,146 @@ func zap_target_to_tags(_ target: ZapTarget) -> [[String]] {
|
||||
}
|
||||
}
|
||||
|
||||
func make_zap_request_event(pubkey: String, privkey: String, content: String, relays: [RelayDescriptor], target: ZapTarget) -> NostrEvent {
|
||||
func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, target: ZapTarget, message: String) -> String? {
|
||||
// target tags must be the same as zap request target tags
|
||||
let tags = zap_target_to_tags(target)
|
||||
|
||||
let note = NostrEvent(content: message, pubkey: identity.pubkey, kind: 9733, tags: tags)
|
||||
note.id = calculate_event_id(ev: note)
|
||||
note.sig = sign_event(privkey: identity.privkey, ev: note)
|
||||
|
||||
guard let note_json = encode_json(note) else {
|
||||
return nil
|
||||
}
|
||||
return encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
|
||||
}
|
||||
|
||||
func decrypt_private_zap(our_privkey: String, zapreq: NostrEvent, target: ZapTarget) -> NostrEvent? {
|
||||
guard let anon_tag = zapreq.tags.first(where: { t in t.count >= 2 && t[0] == "anon" }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let enc_note = anon_tag[1]
|
||||
|
||||
var note = decrypt_note(our_privkey: our_privkey, their_pubkey: zapreq.pubkey, enc_note: enc_note, encoding: .bech32)
|
||||
|
||||
// check to see if the private note was from us
|
||||
if note == nil {
|
||||
guard let our_private_keypair = generate_private_keypair(our_privkey: our_privkey, id: target.id, created_at: zapreq.created_at) else{
|
||||
return nil
|
||||
}
|
||||
// use our private keypair and their pubkey to get the shared secret
|
||||
note = decrypt_note(our_privkey: our_private_keypair.privkey, their_pubkey: target.pubkey, enc_note: enc_note, encoding: .bech32)
|
||||
}
|
||||
|
||||
guard let note else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard note.kind == 9733 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let zr_etag = zapreq.referenced_ids.first
|
||||
let note_etag = note.referenced_ids.first
|
||||
|
||||
guard zr_etag == note_etag else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let zr_ptag = zapreq.referenced_pubkeys.first
|
||||
let note_ptag = note.referenced_pubkeys.first
|
||||
|
||||
guard let zr_ptag, let note_ptag, zr_ptag == note_ptag else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard validate_event(ev: note) == .ok else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
func generate_private_keypair(our_privkey: String, id: String, created_at: Int64) -> FullKeypair? {
|
||||
let to_hash = our_privkey + id + String(created_at)
|
||||
guard let dat = to_hash.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
let privkey_bytes = sha256(dat)
|
||||
let privkey = hex_encode(privkey_bytes)
|
||||
guard let pubkey = privkey_to_pubkey(privkey: privkey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return FullKeypair(pubkey: pubkey, privkey: privkey)
|
||||
}
|
||||
|
||||
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> NostrEvent? {
|
||||
var tags = zap_target_to_tags(target)
|
||||
var relay_tag = ["relays"]
|
||||
relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
|
||||
relay_tag.append(contentsOf: relays.map { $0.url.id })
|
||||
tags.append(relay_tag)
|
||||
let ev = NostrEvent(content: content, pubkey: pubkey, kind: 9734, tags: tags)
|
||||
|
||||
var kp = keypair
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970)
|
||||
|
||||
var message = content
|
||||
switch zap_type {
|
||||
case .pub:
|
||||
break
|
||||
case .non_zap:
|
||||
break
|
||||
case .anon:
|
||||
tags.append(["anon"])
|
||||
kp = generate_new_keypair().to_full()!
|
||||
case .priv:
|
||||
guard let priv_kp = generate_private_keypair(our_privkey: keypair.privkey, id: target.id, created_at: now) else {
|
||||
return nil
|
||||
}
|
||||
kp = priv_kp
|
||||
guard let privreq = make_private_zap_request_event(identity: keypair, enc_key: kp, target: target, message: message) else {
|
||||
return nil
|
||||
}
|
||||
tags.append(["anon", privreq])
|
||||
message = ""
|
||||
}
|
||||
|
||||
let ev = NostrEvent(content: message, pubkey: kp.pubkey, kind: 9734, tags: tags, createdAt: now)
|
||||
ev.id = calculate_event_id(ev: ev)
|
||||
ev.sig = sign_event(privkey: privkey, ev: ev)
|
||||
ev.sig = sign_event(privkey: kp.privkey, ev: ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
func uniq<T: Hashable>(_ xs: [T]) -> [T] {
|
||||
var s = Set<T>()
|
||||
var ys: [T] = []
|
||||
|
||||
for x in xs {
|
||||
if s.contains(x) {
|
||||
continue
|
||||
}
|
||||
s.insert(x)
|
||||
ys.append(x)
|
||||
}
|
||||
|
||||
return ys
|
||||
}
|
||||
|
||||
func gather_reply_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] {
|
||||
var ids = get_referenced_ids(tags: from.tags, key: "e").first.map { [$0] } ?? []
|
||||
|
||||
ids.append(ReferencedId(ref_id: from.id, relay_id: nil, key: "e"))
|
||||
ids.append(contentsOf: from.referenced_pubkeys.filter { $0.ref_id != our_pubkey })
|
||||
ids.append(.e(from.id))
|
||||
ids.append(contentsOf: uniq(from.referenced_pubkeys.filter { $0.ref_id != our_pubkey }))
|
||||
if from.pubkey != our_pubkey {
|
||||
ids.append(ReferencedId(ref_id: from.pubkey, relay_id: nil, key: "p"))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func gather_quote_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] {
|
||||
var ids: [ReferencedId] = [.q(from.id)]
|
||||
if from.pubkey != our_pubkey {
|
||||
ids.append(ReferencedId(ref_id: from.pubkey, relay_id: nil, key: "p"))
|
||||
}
|
||||
@@ -610,14 +789,14 @@ func event_to_json(ev: NostrEvent) -> String {
|
||||
return str
|
||||
}
|
||||
|
||||
func decrypt_dm(_ privkey: String?, pubkey: String, content: String) -> String? {
|
||||
func decrypt_dm(_ privkey: String?, pubkey: String, content: String, encoding: EncEncoding) -> String? {
|
||||
guard let privkey = privkey else {
|
||||
return nil
|
||||
}
|
||||
guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: pubkey) else {
|
||||
return nil
|
||||
}
|
||||
guard let dat = decode_dm_base64(content) else {
|
||||
guard let dat = (encoding == .base64 ? decode_dm_base64(content) : decode_dm_bech32(content)) else {
|
||||
return nil
|
||||
}
|
||||
guard let dat = aes_decrypt(data: dat.content, iv: dat.iv, shared_sec: shared_sec) else {
|
||||
@@ -626,6 +805,13 @@ func decrypt_dm(_ privkey: String?, pubkey: String, content: String) -> String?
|
||||
return String(data: dat, encoding: .utf8)
|
||||
}
|
||||
|
||||
func decrypt_note(our_privkey: String, their_pubkey: String, enc_note: String, encoding: EncEncoding) -> NostrEvent? {
|
||||
guard let dec = decrypt_dm(our_privkey, pubkey: their_pubkey, content: enc_note, encoding: encoding) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return decode_nostr_event_json(json: dec)
|
||||
}
|
||||
|
||||
func get_shared_secret(privkey: String, pubkey: String) -> [UInt8]? {
|
||||
guard let privkey_bytes = try? privkey.bytes else {
|
||||
@@ -671,6 +857,39 @@ struct DirectMessageBase64 {
|
||||
let iv: [UInt8]
|
||||
}
|
||||
|
||||
|
||||
|
||||
func encode_dm_bech32(content: [UInt8], iv: [UInt8]) -> String {
|
||||
let content_bech32 = bech32_encode(hrp: "pzap", content)
|
||||
let iv_bech32 = bech32_encode(hrp: "iv", iv)
|
||||
return content_bech32 + "_" + iv_bech32
|
||||
}
|
||||
|
||||
func decode_dm_bech32(_ all: String) -> DirectMessageBase64? {
|
||||
let parts = all.split(separator: "_")
|
||||
guard parts.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let content_bech32 = String(parts[0])
|
||||
let iv_bech32 = String(parts[1])
|
||||
|
||||
guard let content_tup = try? bech32_decode(content_bech32) else {
|
||||
return nil
|
||||
}
|
||||
guard let iv_tup = try? bech32_decode(iv_bech32) else {
|
||||
return nil
|
||||
}
|
||||
guard content_tup.hrp == "pzap" else {
|
||||
return nil
|
||||
}
|
||||
guard iv_tup.hrp == "iv" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DirectMessageBase64(content: content_tup.data.bytes, iv: iv_tup.data.bytes)
|
||||
}
|
||||
|
||||
func encode_dm_base64(content: [UInt8], iv: [UInt8]) -> String {
|
||||
let content_b64 = base64_encode(content)
|
||||
let iv_b64 = base64_encode(iv)
|
||||
@@ -799,8 +1018,8 @@ func last_etag(tags: [[String]]) -> String? {
|
||||
return e
|
||||
}
|
||||
|
||||
func inner_event_or_self(ev: NostrEvent) -> NostrEvent {
|
||||
guard let inner_ev = ev.inner_event else {
|
||||
func inner_event_or_self(ev: NostrEvent, cache: EventCache) -> NostrEvent {
|
||||
guard let inner_ev = ev.get_inner_event(cache: cache) else {
|
||||
return ev
|
||||
}
|
||||
|
||||
@@ -835,7 +1054,7 @@ func first_eref_mention(ev: NostrEvent, privkey: String?) -> Mention? {
|
||||
extension [ReferencedId] {
|
||||
var pRefs: [ReferencedId] {
|
||||
get {
|
||||
self.filter { ref in
|
||||
Set(self).filter { ref in
|
||||
ref.key == "p"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,19 +37,23 @@ struct NostrFilter: Codable, Equatable {
|
||||
}
|
||||
|
||||
public static func filter_hashtag(_ htags: [String]) -> NostrFilter {
|
||||
return NostrFilter(ids: nil, kinds: nil, referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: nil, hashtag: htags)
|
||||
return NostrFilter(ids: nil, kinds: nil, referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: nil, hashtag: htags.map { $0.lowercased() })
|
||||
}
|
||||
|
||||
public static var filter_text: NostrFilter {
|
||||
return filter_kinds([1])
|
||||
return filter_kinds([NostrKind.text.rawValue])
|
||||
}
|
||||
|
||||
|
||||
public static func filter_ids(_ ids: [String]) -> NostrFilter {
|
||||
return NostrFilter(ids: ids, kinds: nil, referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: nil, hashtag: nil)
|
||||
}
|
||||
|
||||
public static var filter_profiles: NostrFilter {
|
||||
return filter_kinds([0])
|
||||
return filter_kinds([NostrKind.metadata.rawValue])
|
||||
}
|
||||
|
||||
public static var filter_contacts: NostrFilter {
|
||||
return filter_kinds([3])
|
||||
return filter_kinds([NostrKind.contacts.rawValue])
|
||||
}
|
||||
|
||||
public static func filter_authors(_ authors: [String]) -> NostrFilter {
|
||||
|
||||
@@ -21,4 +21,5 @@ enum NostrKind: Int {
|
||||
case chat = 42
|
||||
case list = 30000
|
||||
case zap = 9735
|
||||
case zap_request = 9734
|
||||
}
|
||||
|
||||
@@ -127,6 +127,9 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
|
||||
var uri = s.replacingOccurrences(of: "nostr://", with: "")
|
||||
uri = uri.replacingOccurrences(of: "nostr:", with: "")
|
||||
|
||||
uri = uri.replacingOccurrences(of: "damus://", with: "")
|
||||
uri = uri.replacingOccurrences(of: "damus:", with: "")
|
||||
|
||||
let parts = uri.split(separator: ":")
|
||||
.reduce(into: Array<String>()) { acc, str in
|
||||
guard let decoded = str.removingPercentEncoding else {
|
||||
@@ -137,7 +140,7 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
|
||||
}
|
||||
|
||||
if tag_is_hashtag(parts) {
|
||||
return .filter(NostrFilter.filter_hashtag([parts[1].lowercased()]))
|
||||
return .filter(NostrFilter.filter_hashtag([parts[1]]))
|
||||
}
|
||||
|
||||
if let rid = tag_to_refid(parts) {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//
|
||||
// NostrMetadata.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-05-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
struct NostrMetadata: Codable {
|
||||
let display_name: String?
|
||||
let name: String?
|
||||
let about: String?
|
||||
let website: String?
|
||||
let nip05: String?
|
||||
let picture: String?
|
||||
let banner: String?
|
||||
let lud06: String?
|
||||
let lud16: String?
|
||||
}
|
||||
|
||||
func create_account_to_metadata(_ model: CreateAccountModel) -> NostrMetadata {
|
||||
return NostrMetadata(display_name: model.real_name, name: model.nick_name, about: model.about, website: nil, nip05: nil, picture: nil, banner: nil, lud06: nil, lud16: nil)
|
||||
}
|
||||
@@ -7,13 +7,22 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct CommandResult {
|
||||
let event_id: String
|
||||
let ok: Bool
|
||||
let msg: String
|
||||
}
|
||||
|
||||
enum NostrResponse: Decodable {
|
||||
case event(String, NostrEvent)
|
||||
case notice(String)
|
||||
case eose(String)
|
||||
case ok(CommandResult)
|
||||
|
||||
var subid: String? {
|
||||
switch self {
|
||||
case .ok(_):
|
||||
return nil
|
||||
case .event(let sub_id, _):
|
||||
return sub_id
|
||||
case .eose(let sub_id):
|
||||
@@ -48,9 +57,23 @@ enum NostrResponse: Decodable {
|
||||
let sub_id = try container.decode(String.self)
|
||||
self = .eose(sub_id)
|
||||
return
|
||||
} else if typ == "OK" {
|
||||
var cr: CommandResult
|
||||
do {
|
||||
let event_id = try container.decode(String.self)
|
||||
let ok = try container.decode(Bool.self)
|
||||
let msg = try container.decode(String.self)
|
||||
cr = CommandResult(event_id: event_id, ok: ok, msg: msg)
|
||||
} catch {
|
||||
print(error)
|
||||
throw error
|
||||
}
|
||||
self = .ok(cr)
|
||||
return
|
||||
//ev.pow = count_hash_leading_zero_bits(ev.id)
|
||||
}
|
||||
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "expected EVENT or NOTICE, got \(typ)"))
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "expected EVENT, NOTICE or OK, got \(typ)"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import UIKit
|
||||
class Profiles {
|
||||
var profiles: [String: TimestampedProfile] = [:]
|
||||
var validated: [String: NIP05] = [:]
|
||||
var nip05_pubkey: [String: String] = [:]
|
||||
var zappers: [String: String] = [:]
|
||||
|
||||
func is_validated(_ pk: String) -> NIP05? {
|
||||
|
||||
@@ -14,8 +14,8 @@ public struct RelayInfo: Codable {
|
||||
static let rw = RelayInfo(read: true, write: true)
|
||||
}
|
||||
|
||||
public struct RelayDescriptor: Codable {
|
||||
public let url: URL
|
||||
public struct RelayDescriptor {
|
||||
public let url: RelayURL
|
||||
public let info: RelayInfo
|
||||
}
|
||||
|
||||
@@ -52,14 +52,12 @@ class Relay: Identifiable {
|
||||
let descriptor: RelayDescriptor
|
||||
let connection: RelayConnection
|
||||
|
||||
var last_pong: UInt32
|
||||
var flags: Int
|
||||
|
||||
init(descriptor: RelayDescriptor, connection: RelayConnection) {
|
||||
self.flags = 0
|
||||
self.descriptor = descriptor
|
||||
self.connection = connection
|
||||
self.last_pong = 0
|
||||
}
|
||||
|
||||
func mark_broken() {
|
||||
@@ -81,6 +79,6 @@ enum RelayError: Error {
|
||||
case RelayNotFound
|
||||
}
|
||||
|
||||
func get_relay_id(_ url: URL) -> String {
|
||||
return url.absoluteString
|
||||
func get_relay_id(_ url: RelayURL) -> String {
|
||||
return url.url.absoluteString
|
||||
}
|
||||
|
||||
@@ -5,42 +5,52 @@
|
||||
// Created by William Casarin on 2022-04-02.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Starscream
|
||||
|
||||
enum NostrConnectionEvent {
|
||||
case ws_event(WebSocketEvent)
|
||||
case nostr_event(NostrResponse)
|
||||
}
|
||||
|
||||
final class RelayConnection: WebSocketDelegate {
|
||||
private(set) var isConnected = false
|
||||
private(set) var isConnecting = false
|
||||
private(set) var isReconnecting = false
|
||||
public struct RelayURL: Hashable {
|
||||
private(set) var url: URL
|
||||
|
||||
private(set) var last_connection_attempt: TimeInterval = 0
|
||||
private lazy var socket = {
|
||||
let req = URLRequest(url: url)
|
||||
let socket = WebSocket(request: req, compressionHandler: .none)
|
||||
socket.delegate = self
|
||||
return socket
|
||||
}()
|
||||
private var handleEvent: (NostrConnectionEvent) -> ()
|
||||
private let url: URL
|
||||
|
||||
init(url: URL, handleEvent: @escaping (NostrConnectionEvent) -> ()) {
|
||||
self.url = url
|
||||
self.handleEvent = handleEvent
|
||||
var id: String {
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
if isConnected {
|
||||
isReconnecting = true
|
||||
disconnect()
|
||||
} else {
|
||||
// we're already disconnected, so just connect
|
||||
connect(force: true)
|
||||
init?(_ str: String) {
|
||||
guard let url = URL(string: str) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let scheme = url.scheme else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard scheme == "ws" || scheme == "wss" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
final class RelayConnection {
|
||||
private(set) var isConnected = false
|
||||
private(set) var isConnecting = false
|
||||
|
||||
private(set) var last_connection_attempt: TimeInterval = 0
|
||||
private lazy var socket = WebSocket(url.url)
|
||||
private var subscriptionToken: AnyCancellable?
|
||||
|
||||
private var handleEvent: (NostrConnectionEvent) -> ()
|
||||
private let url: RelayURL
|
||||
|
||||
init(url: RelayURL, handleEvent: @escaping (NostrConnectionEvent) -> ()) {
|
||||
self.url = url
|
||||
self.handleEvent = handleEvent
|
||||
}
|
||||
|
||||
func connect(force: Bool = false) {
|
||||
@@ -50,11 +60,27 @@ final class RelayConnection: WebSocketDelegate {
|
||||
|
||||
isConnecting = true
|
||||
last_connection_attempt = Date().timeIntervalSince1970
|
||||
|
||||
subscriptionToken = socket.subject
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
self?.receive(event: .error(error))
|
||||
case .finished:
|
||||
self?.receive(event: .disconnected(.normalClosure, nil))
|
||||
}
|
||||
} receiveValue: { [weak self] event in
|
||||
self?.receive(event: event)
|
||||
}
|
||||
|
||||
socket.connect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
socket.disconnect()
|
||||
subscriptionToken = nil
|
||||
|
||||
isConnected = false
|
||||
isConnecting = false
|
||||
}
|
||||
@@ -64,55 +90,58 @@ final class RelayConnection: WebSocketDelegate {
|
||||
print("failed to encode nostr req: \(req)")
|
||||
return
|
||||
}
|
||||
|
||||
socket.write(string: req)
|
||||
socket.send(.string(req))
|
||||
}
|
||||
|
||||
// MARK: - WebSocketDelegate
|
||||
|
||||
func didReceive(event: WebSocketEvent, client: WebSocket) {
|
||||
private func receive(event: WebSocketEvent) {
|
||||
switch event {
|
||||
case .connected:
|
||||
self.isConnected = true
|
||||
self.isConnecting = false
|
||||
|
||||
case .disconnected:
|
||||
self.isConnecting = false
|
||||
self.isConnected = false
|
||||
if self.isReconnecting {
|
||||
self.isReconnecting = false
|
||||
self.connect()
|
||||
case .message(let message):
|
||||
self.receive(message: message)
|
||||
case .disconnected(let closeCode, let reason):
|
||||
if closeCode != .normalClosure {
|
||||
print("⚠️ Warning: RelayConnection (\(self.url)) closed with code \(closeCode), reason: \(String(describing: reason))")
|
||||
}
|
||||
|
||||
case .cancelled, .error:
|
||||
self.isConnecting = false
|
||||
self.isConnected = false
|
||||
|
||||
case .text(let txt):
|
||||
if txt.count > 2000 {
|
||||
DispatchQueue.global(qos: .default).async {
|
||||
if let ev = decode_nostr_event(txt: txt) {
|
||||
DispatchQueue.main.async {
|
||||
self.handleEvent(.nostr_event(ev))
|
||||
}
|
||||
return
|
||||
isConnected = false
|
||||
isConnecting = false
|
||||
reconnect()
|
||||
case .error(let error):
|
||||
print("⚠️ Warning: RelayConnection (\(self.url)) error: \(error)")
|
||||
isConnected = false
|
||||
isConnecting = false
|
||||
reconnect()
|
||||
}
|
||||
self.handleEvent(.ws_event(event))
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
guard !isConnecting else {
|
||||
return // we're already trying to connect
|
||||
}
|
||||
disconnect()
|
||||
connect()
|
||||
}
|
||||
|
||||
private func receive(message: URLSessionWebSocketTask.Message) {
|
||||
switch message {
|
||||
case .string(let messageString):
|
||||
DispatchQueue.global(qos: .default).async {
|
||||
if let ev = decode_nostr_event(txt: messageString) {
|
||||
DispatchQueue.main.async {
|
||||
self.handleEvent(.nostr_event(ev))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let ev = decode_nostr_event(txt: txt) {
|
||||
handleEvent(.nostr_event(ev))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
print("decode failed for \(txt)")
|
||||
// TODO: trigger event error
|
||||
|
||||
default:
|
||||
break
|
||||
case .data(let messageData):
|
||||
if let messageString = String(data: messageData, encoding: .utf8) {
|
||||
receive(message: .string(messageString))
|
||||
}
|
||||
@unknown default:
|
||||
print("An unexpected URLSessionWebSocketTask.Message was received.")
|
||||
}
|
||||
|
||||
handleEvent(.ws_event(event))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
struct SubscriptionId: Identifiable, CustomStringConvertible {
|
||||
let id: String
|
||||
@@ -44,7 +45,24 @@ class RelayPool {
|
||||
var request_queue: [QueuedRequest] = []
|
||||
var seen: Set<String> = Set()
|
||||
var counts: [String: UInt64] = [:]
|
||||
|
||||
private let network_monitor = NWPathMonitor()
|
||||
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
|
||||
private var last_network_status: NWPath.Status = .unsatisfied
|
||||
|
||||
init() {
|
||||
network_monitor.pathUpdateHandler = { [weak self] path in
|
||||
if (path.status == .satisfied || path.status == .requiresConnection) && self?.last_network_status != path.status {
|
||||
DispatchQueue.main.async {
|
||||
self?.connect_to_disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
self?.last_network_status = path.status
|
||||
}
|
||||
network_monitor.start(queue: network_monitor_queue)
|
||||
}
|
||||
|
||||
var descriptors: [RelayDescriptor] {
|
||||
relays.map { $0.descriptor }
|
||||
}
|
||||
@@ -52,6 +70,10 @@ class RelayPool {
|
||||
var num_connecting: Int {
|
||||
return relays.reduce(0) { n, r in n + (r.connection.isConnecting ? 1 : 0) }
|
||||
}
|
||||
|
||||
var num_connected: Int {
|
||||
return relays.reduce(0) { n, r in n + (r.connection.isConnected ? 1 : 0) }
|
||||
}
|
||||
|
||||
func remove_handler(sub_id: String) {
|
||||
self.handlers = handlers.filter { $0.sub_id != sub_id }
|
||||
@@ -84,7 +106,7 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
|
||||
func add_relay(_ url: URL, info: RelayInfo) throws {
|
||||
func add_relay(_ url: RelayURL, info: RelayInfo) throws {
|
||||
let relay_id = get_relay_id(url)
|
||||
if get_relay(relay_id) != nil {
|
||||
throw RelayError.RelayAlreadyExists
|
||||
@@ -102,11 +124,11 @@ class RelayPool {
|
||||
for relay in relays {
|
||||
let c = relay.connection
|
||||
|
||||
let is_connecting = c.isReconnecting || c.isConnecting
|
||||
let is_connecting = c.isConnecting
|
||||
|
||||
if is_connecting && (Date.now.timeIntervalSince1970 - c.last_connection_attempt) > 5 {
|
||||
print("stale connection detected (\(relay.descriptor.url.absoluteString)). retrying...")
|
||||
relay.connection.connect(force: true)
|
||||
print("stale connection detected (\(relay.descriptor.url.url.absoluteString)). retrying...")
|
||||
relay.connection.reconnect()
|
||||
} else if relay.is_broken || is_connecting || c.isConnected {
|
||||
continue
|
||||
} else {
|
||||
@@ -204,19 +226,6 @@ class RelayPool {
|
||||
relays.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func record_last_pong(relay_id: String, event: NostrConnectionEvent) {
|
||||
if case .ws_event(let ws_event) = event {
|
||||
if case .pong = ws_event {
|
||||
for relay in relays {
|
||||
if relay.id == relay_id {
|
||||
relay.last_pong = UInt32(Date.now.timeIntervalSince1970)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run_queue(_ relay_id: String) {
|
||||
self.request_queue = request_queue.reduce(into: Array<QueuedRequest>()) { (q, req) in
|
||||
guard req.relay == relay_id else {
|
||||
@@ -246,7 +255,6 @@ class RelayPool {
|
||||
}
|
||||
|
||||
func handle_event(relay_id: String, event: NostrConnectionEvent) {
|
||||
record_last_pong(relay_id: relay_id, event: event)
|
||||
record_seen(relay_id: relay_id, event: event)
|
||||
|
||||
// run req queue when we reconnect
|
||||
@@ -256,7 +264,6 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
|
||||
// handle reconnect logic, etc?
|
||||
for handler in handlers {
|
||||
handler.callback(relay_id, event)
|
||||
}
|
||||
@@ -264,8 +271,10 @@ class RelayPool {
|
||||
}
|
||||
|
||||
func add_rw_relay(_ pool: RelayPool, _ url: String) {
|
||||
let url_ = URL(string: url)!
|
||||
try? pool.add_relay(url_, info: RelayInfo.rw)
|
||||
guard let url = RelayURL(url) else {
|
||||
return
|
||||
}
|
||||
try? pool.add_relay(url, info: RelayInfo.rw)
|
||||
}
|
||||
|
||||
|
||||
|
||||
87
damus/Nostr/WebSocket.swift
Normal file
87
damus/Nostr/WebSocket.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// WebSocket.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Bryan Montz on 4/13/23.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum WebSocketEvent {
|
||||
case connected
|
||||
case message(URLSessionWebSocketTask.Message)
|
||||
case disconnected(URLSessionWebSocketTask.CloseCode, String?)
|
||||
case error(Error)
|
||||
}
|
||||
|
||||
final class WebSocket: NSObject, URLSessionWebSocketDelegate {
|
||||
|
||||
private let url: URL
|
||||
private let session: URLSession
|
||||
private lazy var webSocketTask: URLSessionWebSocketTask = {
|
||||
let task = session.webSocketTask(with: url)
|
||||
task.delegate = self
|
||||
return task
|
||||
}()
|
||||
|
||||
let subject = PassthroughSubject<WebSocketEvent, Never>()
|
||||
|
||||
init(_ url: URL, session: URLSession = .shared) {
|
||||
self.url = url
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func connect() {
|
||||
resume()
|
||||
}
|
||||
|
||||
func disconnect(closeCode: URLSessionWebSocketTask.CloseCode = .normalClosure, reason: Data? = nil) {
|
||||
webSocketTask.cancel(with: closeCode, reason: reason)
|
||||
|
||||
// reset after disconnecting to be ready for reconnecting
|
||||
let task = session.webSocketTask(with: url)
|
||||
task.delegate = self
|
||||
webSocketTask = task
|
||||
|
||||
let reason_str: String?
|
||||
if let reason {
|
||||
reason_str = String(data: reason, encoding: .utf8)
|
||||
} else {
|
||||
reason_str = nil
|
||||
}
|
||||
subject.send(.disconnected(closeCode, reason_str))
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) {
|
||||
webSocketTask.send(message) { [weak self] error in
|
||||
if let error {
|
||||
self?.subject.send(.error(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resume() {
|
||||
webSocketTask.receive { [weak self] result in
|
||||
switch result {
|
||||
case .success(let message):
|
||||
self?.subject.send(.message(message))
|
||||
self?.resume()
|
||||
case .failure(let error):
|
||||
self?.subject.send(.error(error))
|
||||
}
|
||||
}
|
||||
|
||||
webSocketTask.resume()
|
||||
}
|
||||
|
||||
// MARK: - URLSessionWebSocketDelegate
|
||||
|
||||
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol theProtocol: String?) {
|
||||
subject.send(.connected)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
disconnect(closeCode: closeCode, reason: reason)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import Foundation
|
||||
|
||||
|
||||
func created_deleted_account_profile(keypair: FullKeypair) -> NostrEvent {
|
||||
var profile = Profile()
|
||||
let profile = Profile()
|
||||
profile.deleted = true
|
||||
profile.about = "account deleted"
|
||||
profile.name = "nobody"
|
||||
|
||||
146
damus/Util/BlurHash/BlurHashDecode.swift
Normal file
146
damus/Util/BlurHash/BlurHashDecode.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
|
||||
guard blurHash.count >= 6 else { return nil }
|
||||
|
||||
let sizeFlag = String(blurHash[0]).decode83()
|
||||
let numY = (sizeFlag / 9) + 1
|
||||
let numX = (sizeFlag % 9) + 1
|
||||
|
||||
let quantisedMaximumValue = String(blurHash[1]).decode83()
|
||||
let maximumValue = Float(quantisedMaximumValue + 1) / 166
|
||||
|
||||
guard blurHash.count == 4 + 2 * numX * numY else { return nil }
|
||||
|
||||
let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
|
||||
if i == 0 {
|
||||
let value = String(blurHash[2 ..< 6]).decode83()
|
||||
return decodeDC(value)
|
||||
} else {
|
||||
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
|
||||
return decodeAC(value, maximumValue: maximumValue * punch)
|
||||
}
|
||||
}
|
||||
|
||||
let width = Int(size.width)
|
||||
let height = Int(size.height)
|
||||
let bytesPerRow = width * 3
|
||||
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
|
||||
CFDataSetLength(data, bytesPerRow * height)
|
||||
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
|
||||
|
||||
for y in 0 ..< height {
|
||||
for x in 0 ..< width {
|
||||
var r: Float = 0
|
||||
var g: Float = 0
|
||||
var b: Float = 0
|
||||
|
||||
for j in 0 ..< numY {
|
||||
for i in 0 ..< numX {
|
||||
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
|
||||
let colour = colours[i + j * numX]
|
||||
r += colour.0 * basis
|
||||
g += colour.1 * basis
|
||||
b += colour.2 * basis
|
||||
}
|
||||
}
|
||||
|
||||
let intR = UInt8(linearTosRGB(r))
|
||||
let intG = UInt8(linearTosRGB(g))
|
||||
let intB = UInt8(linearTosRGB(b))
|
||||
|
||||
pixels[3 * x + 0 + y * bytesPerRow] = intR
|
||||
pixels[3 * x + 1 + y * bytesPerRow] = intG
|
||||
pixels[3 * x + 2 + y * bytesPerRow] = intB
|
||||
}
|
||||
}
|
||||
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
|
||||
|
||||
guard let provider = CGDataProvider(data: data) else { return nil }
|
||||
guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
|
||||
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil }
|
||||
|
||||
self.init(cgImage: cgImage)
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeDC(_ value: Int) -> (Float, Float, Float) {
|
||||
let intR = value >> 16
|
||||
let intG = (value >> 8) & 255
|
||||
let intB = value & 255
|
||||
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
|
||||
}
|
||||
|
||||
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
|
||||
let quantR = value / (19 * 19)
|
||||
let quantG = (value / 19) % 19
|
||||
let quantB = value % 19
|
||||
|
||||
let rgb = (
|
||||
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
|
||||
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
|
||||
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
|
||||
)
|
||||
|
||||
return rgb
|
||||
}
|
||||
|
||||
private func signPow(_ value: Float, _ exp: Float) -> Float {
|
||||
return copysign(pow(abs(value), exp), value)
|
||||
}
|
||||
|
||||
private func linearTosRGB(_ value: Float) -> Int {
|
||||
let v = max(0, min(1, value))
|
||||
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
|
||||
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
|
||||
}
|
||||
|
||||
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
||||
let v = Float(Int64(value)) / 255
|
||||
if v <= 0.04045 { return v / 12.92 }
|
||||
else { return pow((v + 0.055) / 1.055, 2.4) }
|
||||
}
|
||||
|
||||
private let encodeCharacters: [String] = {
|
||||
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
|
||||
}()
|
||||
|
||||
private let decodeCharacters: [String: Int] = {
|
||||
var dict: [String: Int] = [:]
|
||||
for (index, character) in encodeCharacters.enumerated() {
|
||||
dict[character] = index
|
||||
}
|
||||
return dict
|
||||
}()
|
||||
|
||||
extension String {
|
||||
func decode83() -> Int {
|
||||
var value: Int = 0
|
||||
for character in self {
|
||||
if let digit = decodeCharacters[String(character)] {
|
||||
value = value * 83 + digit
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
subscript (offset: Int) -> Character {
|
||||
return self[index(startIndex, offsetBy: offset)]
|
||||
}
|
||||
|
||||
subscript (bounds: CountableClosedRange<Int>) -> Substring {
|
||||
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||
return self[start...end]
|
||||
}
|
||||
|
||||
subscript (bounds: CountableRange<Int>) -> Substring {
|
||||
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||
return self[start..<end]
|
||||
}
|
||||
}
|
||||
145
damus/Util/BlurHash/BlurHashEncode.swift
Normal file
145
damus/Util/BlurHash/BlurHashEncode.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
public func blurHash(numberOfComponents components: (Int, Int)) -> String? {
|
||||
let pixelWidth = Int(round(size.width * scale))
|
||||
let pixelHeight = Int(round(size.height * scale))
|
||||
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: pixelWidth,
|
||||
height: pixelHeight,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: pixelWidth * 4,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
)!
|
||||
context.scaleBy(x: scale, y: -scale)
|
||||
context.translateBy(x: 0, y: -size.height)
|
||||
|
||||
UIGraphicsPushContext(context)
|
||||
draw(at: .zero)
|
||||
UIGraphicsPopContext()
|
||||
|
||||
guard let cgImage = context.makeImage(),
|
||||
let dataProvider = cgImage.dataProvider,
|
||||
let data = dataProvider.data,
|
||||
let pixels = CFDataGetBytePtr(data) else {
|
||||
assertionFailure("Unexpected error!")
|
||||
return nil
|
||||
}
|
||||
|
||||
let width = cgImage.width
|
||||
let height = cgImage.height
|
||||
let bytesPerRow = cgImage.bytesPerRow
|
||||
|
||||
var factors: [(Float, Float, Float)] = []
|
||||
for y in 0 ..< components.1 {
|
||||
for x in 0 ..< components.0 {
|
||||
let normalisation: Float = (x == 0 && y == 0) ? 1 : 2
|
||||
let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) {
|
||||
normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float
|
||||
}
|
||||
factors.append(factor)
|
||||
}
|
||||
}
|
||||
|
||||
let dc = factors.first!
|
||||
let ac = factors.dropFirst()
|
||||
|
||||
var hash = ""
|
||||
|
||||
let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9
|
||||
hash += sizeFlag.encode83(length: 1)
|
||||
|
||||
let maximumValue: Float
|
||||
if ac.count > 0 {
|
||||
let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()!
|
||||
let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5))))
|
||||
maximumValue = Float(quantisedMaximumValue + 1) / 166
|
||||
hash += quantisedMaximumValue.encode83(length: 1)
|
||||
} else {
|
||||
maximumValue = 1
|
||||
hash += 0.encode83(length: 1)
|
||||
}
|
||||
|
||||
hash += encodeDC(dc).encode83(length: 4)
|
||||
|
||||
for factor in ac {
|
||||
hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2)
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
private func multiplyBasisFunction(pixels: UnsafePointer<UInt8>, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) {
|
||||
var r: Float = 0
|
||||
var g: Float = 0
|
||||
var b: Float = 0
|
||||
|
||||
let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow)
|
||||
|
||||
for x in 0 ..< width {
|
||||
for y in 0 ..< height {
|
||||
let basis = basisFunction(Float(x), Float(y))
|
||||
r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow])
|
||||
g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow])
|
||||
b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow])
|
||||
}
|
||||
}
|
||||
|
||||
let scale = 1 / Float(width * height)
|
||||
|
||||
return (r * scale, g * scale, b * scale)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeDC(_ value: (Float, Float, Float)) -> Int {
|
||||
let roundedR = linearTosRGB(value.0)
|
||||
let roundedG = linearTosRGB(value.1)
|
||||
let roundedB = linearTosRGB(value.2)
|
||||
return (roundedR << 16) + (roundedG << 8) + roundedB
|
||||
}
|
||||
|
||||
private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
|
||||
let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
|
||||
let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
|
||||
let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))
|
||||
|
||||
return quantR * 19 * 19 + quantG * 19 + quantB
|
||||
}
|
||||
|
||||
private func signPow(_ value: Float, _ exp: Float) -> Float {
|
||||
return copysign(pow(abs(value), exp), value)
|
||||
}
|
||||
|
||||
private func linearTosRGB(_ value: Float) -> Int {
|
||||
let v = max(0, min(1, value))
|
||||
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
|
||||
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
|
||||
}
|
||||
|
||||
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
||||
let v = Float(Int64(value)) / 255
|
||||
if v <= 0.04045 { return v / 12.92 }
|
||||
else { return pow((v + 0.055) / 1.055, 2.4) }
|
||||
}
|
||||
|
||||
private let encodeCharacters: [String] = {
|
||||
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
|
||||
}()
|
||||
|
||||
extension BinaryInteger {
|
||||
func encode83(length: Int) -> String {
|
||||
var result = ""
|
||||
for i in 1 ... length {
|
||||
let digit = (Int(self) / pow(83, length - i)) % 83
|
||||
result += encodeCharacters[Int(digit)]
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private func pow(_ base: Int, _ exponent: Int) -> Int {
|
||||
return (0 ..< exponent).reduce(1) { value, _ in value * base }
|
||||
}
|
||||
19
damus/Util/BlurHash/License.txt
Normal file
19
damus/Util/BlurHash/License.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2018 Wolt Enterprises
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
45
damus/Util/BlurHash/Readme.md
Normal file
45
damus/Util/BlurHash/Readme.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# BlurHash for iOS, in Swift
|
||||
|
||||
## Standalone decoder and encoder
|
||||
|
||||
[BlurHashDecode.swift](BlurHashDecode.swift) and [BlurHashEncode.swift](BlurHashEncode.swift) contain a decoder
|
||||
and encoder for BlurHash to and from `UIImage`. Both files are completeiy standalone, and can simply be copied into your
|
||||
project directly.
|
||||
|
||||
### Decoding
|
||||
|
||||
[BlurHashDecode.swift](BlurHashDecode.swift) implements the following extension on `UIImage`:
|
||||
|
||||
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1)
|
||||
|
||||
This creates a UIImage containing the placeholder image decoded from the BlurHash string, or returns nil if decoding failed.
|
||||
The parameters are:
|
||||
|
||||
* `blurHash` - A string containing the BlurHash.
|
||||
* `size` - The requested output size. You should keep this small, and let UIKit scale it up for you. 32 pixels wide is plenty.
|
||||
* `punch` - Adjusts the contrast of the output image. Tweak it if you want a different look for your placeholders.
|
||||
|
||||
### Encoding
|
||||
|
||||
[BlurHashEncode.swift](BlurHashEncode.swift) implements the following extension on `UIImage`:
|
||||
|
||||
public func blurHash(numberOfComponents components: (Int, Int)) -> String?
|
||||
|
||||
This returns a string containing the BlurHash for the image, or nil if the image was in a weird format that is not supported.
|
||||
The parameters are:
|
||||
|
||||
* `numberOfComponents` - a Tuple of integers specifying the number of components in the X and Y directions. Both must be
|
||||
between 1 and 9 inclusive, or the function will return nil. 3 to 5 is usually a good range.
|
||||
|
||||
## BlurHashKit
|
||||
|
||||
This is a more advanced library, currently in development. It will let you do more advanced operations using BlurHashes,
|
||||
such testing whether various parts of an image are dark and light, or generating BlurHashes as gradients from corner colours.
|
||||
|
||||
It is currently not documented or finalised, but feel free to look into the different files and what they implement, or look at
|
||||
how it is used by the test app.
|
||||
|
||||
## BlurHashTest.app
|
||||
|
||||
This is a simple test app that shows how to use the various pieces of BlurHash functionality, and lets you play with the
|
||||
algorithm.
|
||||
44
damus/Util/CompatibleAttribute.swift
Normal file
44
damus/Util/CompatibleAttribute.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// CompatibleAttribute.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-06.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class CompatibleText: Equatable {
|
||||
var text: Text
|
||||
var attributed: AttributedString
|
||||
|
||||
init() {
|
||||
self.text = Text("")
|
||||
self.attributed = AttributedString(stringLiteral: "")
|
||||
}
|
||||
|
||||
init(stringLiteral: String) {
|
||||
self.text = Text(stringLiteral)
|
||||
self.attributed = AttributedString(stringLiteral: stringLiteral)
|
||||
}
|
||||
|
||||
init(text: Text, attributed: AttributedString) {
|
||||
self.text = text
|
||||
self.attributed = attributed
|
||||
}
|
||||
|
||||
init(attributed: AttributedString) {
|
||||
self.text = Text(attributed)
|
||||
self.attributed = attributed
|
||||
}
|
||||
|
||||
static func == (lhs: CompatibleText, rhs: CompatibleText) -> Bool {
|
||||
return lhs.attributed == rhs.attributed
|
||||
}
|
||||
|
||||
static func +(lhs: CompatibleText, rhs: CompatibleText) -> CompatibleText {
|
||||
let combinedText = lhs.text + rhs.text
|
||||
let combinedAttributes = lhs.attributed + rhs.attributed
|
||||
return CompatibleText(text: combinedText, attributed: combinedAttributes)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user