Compare commits
935 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca20bbd298 | |||
| c6da06cd4d | |||
| 50bf6c04c1 | |||
| 490417771c | |||
| 0cb491eaf9 | |||
| ece6bcc125 | |||
| 4b79e559d2 | |||
| 322e510db2 | |||
| 4e279f127d | |||
| 5655a8136d | |||
| d80534c51f | |||
| 0b97248fb8 | |||
| f54f448ecb | |||
| bd1f2b899d | |||
| efd3c83193 | |||
| 85fa1e2359 | |||
| cd6ba5884f | |||
| bfed56ba13 | |||
| d1018ba8d1 | |||
| a42542c16e | |||
| 26a6ec954c | |||
| f346282c3c | |||
| f3e875eeea | |||
| 6ad8ffddf0 | |||
| d37e2a3c80 | |||
| 18e1ac0e6c | |||
| aa4f21a869 | |||
| bbc052eebc | |||
| c201b5816c | |||
| 043cabfd4e | |||
| 9b02ab5842 | |||
| 618a45d349 | |||
| 11dcef4e87 | |||
| d87371aec4 | |||
| cfb017f70b | |||
| 9ba95301db | |||
| 0518389f50 | |||
| eb6e3e52df | |||
| 1e95a2fd95 | |||
| 83d24351cd | |||
| 470dc1c759 | |||
| 42b780ce6a | |||
| 5ab2b1ae31 | |||
| 055d73c829 | |||
| 469296790e | |||
| c032dbea1a | |||
| 172566028b | |||
|
|
cc7de41bfd | ||
| ba9c81a10a | |||
| e158f2e4d7 | |||
| 62bd689031 | |||
| cb6006f596 | |||
| adad048873 | |||
| 790fee6c05 | |||
| 9f5b14956a | |||
| e04497d841 | |||
| 106c627ec4 | |||
| c40762cc04 | |||
| d2b5ae0507 | |||
| 8c6aea8050 | |||
|
|
090a815f99 | ||
| d841163ba7 | |||
| 8398ae80d3 | |||
| fe60f75e96 | |||
|
|
e098743d43 | ||
| 0c19ada1ab | |||
| 09db39fce1 | |||
|
|
f0fc89724d | ||
| afa9327bb7 | |||
| 5c3644f977 | |||
| 0a8eed9a46 | |||
| bacfaed48a | |||
| 3d5085785b | |||
| 9152c3e122 | |||
| a5574bef6c | |||
| 2c7f3685b6 | |||
| be0abc4075 | |||
| dafe35cd1f | |||
| b23903240b | |||
| 872a6cee36 | |||
|
|
ac7ce726c5 | ||
|
|
e5e290c0c3 | ||
| 2eab6f04c7 | |||
|
|
e06b0334a5 | ||
|
|
74d8bf2ead | ||
|
|
d128af1db8 | ||
|
|
f6eb5eea44 | ||
|
|
bca2e0b7b7 | ||
|
|
61ad96ca63 | ||
|
|
26ae473521 | ||
|
|
bcc5e18082 | ||
|
|
307fff7a53 | ||
|
|
ce7828310b | ||
|
|
beac1a189e | ||
|
|
4cb49d44c7 | ||
|
|
be16d5c21d | ||
|
|
da8162069b | ||
|
|
e2103ae23a | ||
|
|
4c6d1c768a | ||
|
|
9b75a04f91 | ||
|
|
a5255fa503 | ||
|
|
954a17b541 | ||
|
|
a55b31b0e6 | ||
|
|
bdf3ffd7bf | ||
|
|
07ce253f5b | ||
|
|
f3db010c74 | ||
|
|
dcf2791fe5 | ||
|
|
8fcf3551d8 | ||
|
|
2d987849d8 | ||
|
|
3b99926f3b | ||
|
|
113d69a4df | ||
|
|
5d12ba7216 | ||
|
|
72b59020b4 | ||
|
|
4c323b9daa | ||
|
|
72da83d648 | ||
|
|
783a4538a4 | ||
|
|
15e62cad11 | ||
|
|
c52b20ca80 | ||
|
|
04706a6d7c | ||
|
|
0755cbeb6c | ||
|
|
8eb01c8bbf | ||
|
|
ed4f89ff66 | ||
|
|
d9fe647f8e | ||
|
|
843c2d52e7 | ||
|
|
017a3676a4 | ||
|
|
fcb70c0e9a | ||
|
|
0fec21b9ce | ||
|
|
968b1ada94 | ||
|
|
5c9b599b1e | ||
|
|
717c3e17df | ||
|
|
a4540a0802 | ||
|
|
31bacc2646 | ||
|
|
6e5d0f0e76 | ||
|
|
f0712e5961 | ||
|
|
3fbd66dece | ||
|
|
1283432632 | ||
|
|
59eaaec903 | ||
|
|
4f0f210076 | ||
|
|
e4a317f038 | ||
|
|
9779d020c7 | ||
|
|
f8280ec8ee | ||
|
|
6c26f8967b | ||
|
|
e1424b851c | ||
|
|
d14e609579 | ||
|
|
8c0627ff27 | ||
|
|
18c133d096 | ||
|
|
0061ecea78 | ||
|
|
d01cf8319d | ||
|
|
843895d876 | ||
|
|
7c99ed39e4 | ||
|
|
71be59b2e9 | ||
|
|
1c20512ecc | ||
|
|
90342c552f | ||
|
|
b396c8a695 | ||
|
|
6996e30889 | ||
|
|
7ba793fad8 | ||
|
|
f11f836518 | ||
|
|
04fe0fcec8 | ||
|
|
799835a629 | ||
|
|
4e7da4108b | ||
|
|
7c7b082b3a | ||
|
|
38d6c51921 | ||
|
|
1738cbdd97 | ||
|
|
2e885b76a1 | ||
|
|
f94680e487 | ||
|
|
c682a58842 | ||
|
|
921cf871ee | ||
|
|
d5b1593aca | ||
|
|
6676b4e2a4 | ||
|
|
5f30ddcfca | ||
|
|
41d0de539d | ||
|
|
e254ee3203 | ||
|
|
6d42360549 | ||
|
|
70c5143445 | ||
|
|
41b66b18f5 | ||
|
|
dda0720ed4 | ||
|
|
4b60b39119 | ||
|
|
d2e5122d5a | ||
|
|
32f3315344 | ||
|
|
5ca9444358 | ||
|
|
4dc13385a5 | ||
|
|
b90ad1421f | ||
|
|
bba324ea53 | ||
|
|
7449000f5f | ||
|
|
dc7762ca11 | ||
|
|
3a3f960dde | ||
|
|
12e066ff2e | ||
|
|
fe4f965ed5 | ||
|
|
5d3f2264e9 | ||
|
|
407fe40b67 | ||
|
|
1f38eba2cc | ||
|
|
9b5867f80c | ||
|
|
cac774a0c1 | ||
|
|
82689bf3c3 | ||
|
|
f60e438a64 | ||
|
|
ca06f2b6ed | ||
|
|
99d9c70826 | ||
|
|
60afbf090b | ||
|
|
10ca4e6ff4 | ||
|
|
b0f387d029 | ||
|
|
1a8f750640 | ||
|
|
25523229a2 | ||
|
|
47835ed857 | ||
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df | ||
|
|
f1e17ff3c4 | ||
|
|
32954f17b6 | ||
|
|
cf70b0f882 | ||
|
|
135d0918b3 | ||
|
|
e1fbcf0460 | ||
|
|
99aaf3da82 | ||
|
|
3ef13e43f1 | ||
|
|
8939196ae4 | ||
|
|
571d4b4004 | ||
|
|
73f80f27fb | ||
|
|
b46a5cf68f | ||
|
|
8c0d03aed0 | ||
|
|
777eb15b4f | ||
|
|
c8e1b8b8bd | ||
|
|
437cd71f7e | ||
|
|
afb7c87fa3 | ||
|
|
c843626bca | ||
|
|
28337e5915 | ||
|
|
a4aef25adb | ||
|
|
61d1f095d4 | ||
|
|
f027eae52d | ||
|
|
174a3cc74e | ||
|
|
c00a7749b4 | ||
|
|
c755b8d137 | ||
| 17766d29d6 | |||
| 3b13dfeed8 | |||
| 17ba79e01b | |||
| bafad544e9 | |||
| 89c36423ae | |||
| cd31b99559 | |||
| f3c52237fa | |||
| 413d8d82df | |||
| 2eb2010d43 | |||
| 94d400cab2 | |||
| 09b143cb08 | |||
| e3ede34108 | |||
| ed6aca41ea | |||
| 89f577fbef | |||
| a14aeaeb55 | |||
|
|
35cf0abda4 | ||
| 8a7b246315 | |||
|
|
d98c6d0709 | ||
| bda20e8fe8 | |||
| c342daecc8 | |||
| 5e6692cd6d | |||
| 420be77b5c | |||
| 999073f84c | |||
| 174b28f1a7 | |||
| 763cb10e85 | |||
| 89bb8d88f6 | |||
| 09aa2ecafc | |||
| 7271e9ea87 | |||
| a02e496b29 | |||
| cbbf5eaf50 | |||
| d3fa59d2b1 | |||
| aa23e39334 | |||
| c8e2018fd0 | |||
| 6e489f1c49 | |||
| a49b88ab35 | |||
| 31839531ea | |||
| ec0f3fabc0 | |||
| dd7155a3a6 | |||
| cb565ff35b | |||
| 5d59040224 | |||
| 7fabf949c6 | |||
| ea5120e2f0 | |||
| 14f07dfea8 | |||
|
|
1de48cc640 | ||
|
|
f72eb456e8 | ||
| 05b52564e0 | |||
| c8e014f33e | |||
| 46cc01e0ee | |||
| 16e6d234e5 | |||
| 3005d27403 | |||
| 1be84f3139 | |||
|
|
04bde7dd43 | ||
| f1504d99ac | |||
| e928f2ee37 | |||
|
|
ccab78ca11 | ||
| 5b13c3e45c | |||
| e6b97ab9ae | |||
| bb6badfed6 | |||
|
|
0c4b309a11 | ||
| f4400d711f | |||
| a3e46aa96b | |||
| a4fdcfdf0b | |||
| 25d07303a3 | |||
| 0e1e7524c9 | |||
|
|
32d770eb91 | ||
| e93cbf78d5 | |||
| 71a2290b8f | |||
| 95294a80cb | |||
| 8eaf47f6d2 | |||
| 86183d799a | |||
| 2dbfff3c17 | |||
|
|
d1f5c372ae | ||
| 13281455ed | |||
| 9cfb32be4d | |||
| cb0672c22a | |||
| ca0e041731 | |||
| cfcb9bc6ed | |||
| 09df8672d0 | |||
| 2403231ac4 | |||
| 98ef1927f2 | |||
| 63db8b1423 | |||
| 2c8dd71792 | |||
|
|
fe73e1428b | ||
| 88a6c3c81f | |||
| 84584a4d1f | |||
| 64286aa354 | |||
| e9ce932646 | |||
|
|
4c28b4879c | ||
| 9127d5c5ea | |||
| 090e54ec5a | |||
| d0c9f93ebb | |||
| e2cdc5b576 | |||
| bfe35ad885 | |||
| 0f06522c28 | |||
| f47eba5af7 | |||
| f28a7ae82f | |||
| 296b11b7b8 | |||
| a0d9a729dd | |||
| 1de8c7240d | |||
| 70126ef1b3 | |||
| cdf29f8a54 | |||
| 6171b9bed1 | |||
| 60fd09000b | |||
| 4292def206 | |||
| 90f149b09c | |||
| ed52105c02 | |||
| 1950cb59a2 | |||
|
|
9753e6d6b4 | ||
|
|
3488f05960 | ||
| c809ab6b4e | |||
|
|
c6674c4a2d | ||
| 35c5b5fb78 | |||
| 739ba63e6c | |||
| ec78cf8bf7 | |||
| 17052aeeaa | |||
| d7bbda6e7b | |||
| 6a08c1de10 | |||
| 3c4bd39384 | |||
| a4069dae99 | |||
|
|
55cd556cd6 | ||
| a21da11a91 | |||
| 08fa7de01d | |||
| 2a58326cd1 | |||
| 8a17c36a5b | |||
| 7bd6f6a8db | |||
| 3ba870be4b | |||
| bd2b6a3759 | |||
| a3a8f57bfc | |||
| fd393a4d30 | |||
| e8bd48e51b | |||
| 0191180f31 | |||
| 60ed56b1b9 | |||
|
|
da722afed3 | ||
| d8eb51e49c | |||
| c700a45ab6 | |||
| b806a34edb | |||
| 21989e6fa5 | |||
| 0539c5649d | |||
| ad488ff72d | |||
| 02e0309a41 | |||
| b7f4af7883 | |||
| cc48a4f36b | |||
| 46ed3330fc | |||
| 1fa1872ca6 | |||
|
|
c389a23365 | ||
| eaf9bda077 | |||
| 84a248a5a9 | |||
|
|
711c1d561a | ||
| 21210b4336 | |||
| 3bd480b75e | |||
| 2b19650e46 | |||
| 23482531c5 | |||
| cfda9ba899 | |||
| 698bd78684 | |||
| b97676dd3e | |||
| 25ae4f2201 | |||
|
|
59435ccd13 | ||
|
|
e81912c5e9 | ||
| af1b4e60d3 | |||
| 648cbf6f80 | |||
| 7b06a82ee7 | |||
|
|
d18de93c60 | ||
| df15eb7a03 | |||
|
|
06674df6cc | ||
|
|
8295625a44 | ||
| b11e2a4291 | |||
| 353c18bb76 | |||
|
|
02b0c9e48a | ||
|
|
ff73c8ac88 | ||
|
|
bc48391a1a | ||
| b0a443c002 | |||
| bef1f136ad | |||
| 9ba584bf14 | |||
|
|
43509fc943 | ||
|
|
4a99eb94e2 | ||
| 74426e13c8 | |||
| bd45c36072 | |||
| c13aefcd15 | |||
| 167caee8bc | |||
| d527078d5c | |||
|
|
763ace5ddf | ||
| 057c57b70f | |||
| cb71786ac1 | |||
|
|
67afeac198 | ||
| f4ee25de8e | |||
| 445a218a9e | |||
| f09139ffbe | |||
| 446721729b | |||
|
|
e0250d7f5c | ||
| 9fcdac4edb | |||
| b726ae3c7c | |||
| a3460418f6 | |||
| f65175f11e | |||
| 16efd495a0 | |||
| ed6423e4aa | |||
| 0e9418949b | |||
| 240fe8bc7c | |||
| c3482cddd8 | |||
| d13e7b3ef6 | |||
| 47800bd2ff | |||
| c0305db5fc | |||
| 0b745cb40e | |||
| a20f5ca15d | |||
| c29b4e173e | |||
| 33dd8b1d8a | |||
| 1503d90bd5 | |||
| 6581ffb92b | |||
| 939dfd9cc1 | |||
| 7744a5e17c | |||
| 3301af5cbb | |||
| 3f1218e7bc | |||
| fbcb3ae6dc | |||
| e93aedb703 | |||
| dae4b1d52b | |||
| f908c46a19 | |||
| ab27bd5f44 | |||
| 72870bb131 | |||
| 1822eac488 | |||
| 0487b8a801 | |||
| 67c6177291 | |||
| ad6ae6745d | |||
| a9d10ff93b | |||
| e0d4c53098 | |||
| 2c8571ecc7 | |||
| a8cd34d998 | |||
| a5ad4fe05c | |||
| f2504071cd | |||
| 73f90ebaf9 | |||
| c172c0f80f | |||
| aa80301778 | |||
| c04ca3a1ab | |||
| 3eae38e1cb | |||
| 87099c6388 | |||
| 7554a35c31 | |||
| 70707f69c8 | |||
|
|
a98ffd4887 | ||
|
|
2e23b3ae06 | ||
| 8e8e6fe244 | |||
| 2726bfd595 | |||
| 542b6033c2 | |||
| fcde669685 | |||
| f4cbcee8b4 | |||
| ba13ac7535 | |||
| 9f27d68533 | |||
| 698f5a5d6d | |||
| 7856d6d49d | |||
| a52fb3c437 | |||
| 499765c10a | |||
| 56fab1dda6 | |||
| b1d2496f8e | |||
| ddbbcf41b5 | |||
| 55d6318614 | |||
| be333260f2 | |||
| e1edba8a78 | |||
| 4fc3cc8a80 | |||
| 893f3f7181 | |||
| 4103b509d4 | |||
| ed538c91c6 | |||
| b4dac2d477 | |||
| 3956ed622d | |||
| e1db873bd5 | |||
| 227c2ddefa | |||
| a6da07cd3f | |||
| 9591d8626d | |||
| ee4e6b1ee6 | |||
| a882ead649 | |||
| 0522611669 | |||
| 2536630ff7 | |||
| 4670778181 | |||
| a6ca2589ab | |||
| d9e8d05db7 | |||
| ec2ac2dce3 | |||
| 55298515af | |||
| 344bdc0c66 | |||
| ba88a4e0f2 | |||
| 17c64ee357 | |||
| ba93bdbb91 | |||
| 591373fd52 | |||
| 2fcc4dead1 | |||
| d9ab7893e0 | |||
|
|
a93ebd3861 | ||
| 7c4ec71089 | |||
|
|
e9d845cf25 | ||
| 8883be7ed6 | |||
| 132ea7f887 | |||
|
|
f9402f5c4f | ||
| 72a38e3aa7 | |||
| 38e82a4feb | |||
| 6440680898 | |||
| e507187044 | |||
|
|
feeb92b6ef | ||
|
|
2d4a77e8ed | ||
| 6f5ea1229d | |||
| 68886ad584 | |||
| 5f90bd0d22 | |||
| 8b434d577f | |||
| f2b1458bd2 | |||
| 7507cd9ba1 | |||
| 0d43c13928 | |||
| 95124e5ded | |||
| a42a2788ea | |||
| e30274dab3 | |||
|
|
740b7588bc | ||
| 24c2ed4eb2 | |||
| 4006c0010e | |||
| 7decf264d7 | |||
| 482b218f74 | |||
| e06b760e41 | |||
| 7efc35f622 | |||
| 8795923443 | |||
| 4093821fd0 | |||
| b19637bdb7 | |||
| 21e758ec13 | |||
| 48ab123850 | |||
| a401070031 | |||
| e5e4109e79 | |||
|
|
d62c814f33 | ||
| 2a92b7c202 | |||
| 255dcb43fe | |||
| a528b646e3 | |||
| fc35745c0d | |||
| 9ddf3471ce | |||
| 8355ad6863 | |||
| 217ac490b1 | |||
| 092cf49227 | |||
| 5318f6c4cb | |||
| 80f675cb54 | |||
| 6f68c2762b | |||
| f4390b29e2 | |||
| 00e4f9d357 | |||
| d28d183620 | |||
| 3c6c9c86d1 | |||
| bcd079c88e | |||
|
|
d989d6ffad | ||
| 5229458746 | |||
| 2bfa1db816 | |||
| 8439428ce1 | |||
| 34dceef4a3 | |||
|
|
619bfb8dff | ||
| 7759851541 | |||
| 9112c1c24a | |||
| 24b21a9451 | |||
| 31a53b9c48 | |||
| dc229f40cb | |||
| 54ad1e6e1d | |||
|
|
065ccbbea4 | ||
| 74738c36cd | |||
|
|
2fdf437789 | ||
| 731c72535c | |||
| 628102087e | |||
| 536ea30ed2 | |||
| 8ee38cdb42 | |||
|
|
a896300f23 | ||
| d3cf1200ba | |||
| b5ac3df090 | |||
| 3b40dd6903 | |||
|
|
efba6b20ea | ||
|
|
05fb56e5fc | ||
|
|
59d9646e9f | ||
| b73d84fccb | |||
| 1929ceb72d | |||
| a1d22c1daf | |||
| cf7af1ba64 | |||
| 933ca758ee | |||
| f537209b92 | |||
| 6777610b07 | |||
| 88803cd3cd | |||
|
|
6adf5933b0 | ||
| 9521a49fff | |||
|
|
5789a105f5 | ||
| b7a18bea34 | |||
| 7117ed05a9 | |||
| c53bdb68e5 | |||
| 6725dca807 | |||
|
|
077712cf43 | ||
| 2794c78ee1 | |||
| 21574023db | |||
| 954b729dc9 | |||
| 6dc4e1cde6 | |||
| efbdf26706 | |||
| b41ec353c6 | |||
| 875225591a | |||
| 04c1223f2e | |||
| 773e49afa2 | |||
| 025d7a623b | |||
| b6caab15e1 | |||
| 1d3d0a17dc | |||
| 1cbe781698 | |||
| dc5b4f8ac1 | |||
| fee4ad7b98 | |||
| d5647d7452 | |||
| 0a5076f06c | |||
| a3632571ff | |||
| 5c48ebe103 | |||
| 1c3119577f | |||
| 0710996a0d | |||
| 0cdf199cb5 | |||
| cb9006abb2 | |||
| 108ecafab7 | |||
| 6b030f2902 | |||
| ce864c8990 | |||
| ee3e8eb105 | |||
| 701712e7b8 | |||
| dad388c6ab | |||
|
|
912c740c51 | ||
| da0b48c5df | |||
| 64b4745993 | |||
| 8aa2ef39c5 | |||
| a945f04959 | |||
| c93989237a | |||
| 95cf3f60f3 | |||
|
|
dd21633624 | ||
| f01074ea9f | |||
| c8d04f4695 | |||
| 2f8aa66ff6 | |||
| 3ad6830bfb | |||
| f2dddf97f5 | |||
| e218ebee89 | |||
| fd5ecc18a9 | |||
| e7738fb128 | |||
| 0b25a4a04b | |||
| ace58ecdd5 | |||
| 6685d9af38 | |||
| 60b803f419 | |||
| 555c8ec08a | |||
| 42eb882f52 | |||
| b6784aa979 | |||
| 08a7bdc6d5 | |||
| 6fac37a0ca | |||
| 37ed0f4892 | |||
| 1cb2d8cb41 | |||
| 3abce5e6d6 | |||
| 842f9e14e0 | |||
| 0c8dcef937 | |||
|
|
50f81a7d0b | ||
| dcacf23625 | |||
|
|
b01af39445 | ||
| 507628bcaa | |||
|
|
4dfea49f71 | ||
| 854a47f266 | |||
| b1a44f2cbf | |||
| cade8c8b4c | |||
| de88ca51fe | |||
| 7c8d8a09fd | |||
| e1e54c1a98 | |||
| 0de72eb009 | |||
| 823fb0f239 | |||
|
|
3660db4887 | ||
| b18ae56c36 | |||
| 939a72f945 | |||
| 489ab6bd0b | |||
| 7fa1e89dc8 | |||
| 3aa4f294f9 | |||
| cd3b9ada5a | |||
| 620e763380 | |||
| 0777c483e5 | |||
| 893663561d | |||
| 35650a40f2 | |||
|
|
3b46e71525 | ||
|
|
2fcbf1987b | ||
|
|
c3f399ea0b | ||
|
|
770a63de63 | ||
|
|
bc4c3b9803 | ||
|
|
043c1b1220 | ||
|
|
d20ee26e22 | ||
|
|
8930300fb5 | ||
|
|
140b8a47bf | ||
|
|
ced23341d2 | ||
|
|
0946e9125e | ||
|
|
bce76bd41c | ||
|
|
cb91373d33 | ||
|
|
c71bfb3f6d | ||
| 9627c40d75 | |||
|
|
1240353e30 | ||
|
|
cef6b9aca9 | ||
|
|
508a746578 | ||
|
|
222ef2ca32 | ||
|
|
32843018aa | ||
|
|
f80dd78a8e | ||
|
|
9df4835be3 | ||
|
|
480580890e | ||
|
|
ca57ef1760 | ||
|
|
8e39bca57c | ||
|
|
8d9ec0dcfd | ||
|
|
428d52f175 | ||
|
|
cdeb5afd28 | ||
|
|
1f3ba09cec | ||
|
|
4915b833e7 | ||
|
|
674e5f0339 | ||
|
|
11ed618a7f | ||
|
|
a2e3247432 | ||
|
|
09b3eeda99 | ||
|
|
700f3eb85f | ||
|
|
2f87ed8949 | ||
|
|
cb3c95b133 | ||
|
|
4f4e2f5ccd | ||
|
|
0e6fc65b08 | ||
|
|
876d351358 | ||
|
|
c80414a72d | ||
|
|
7cef6efa6f | ||
|
|
74ff49b8db | ||
|
|
2b50fc438f | ||
|
|
b339e842ca | ||
|
|
1d93f8cf6a | ||
|
|
236131087a | ||
|
|
a66770989b | ||
|
|
9ff74599eb | ||
|
|
c049fa8865 | ||
|
|
41b12746a7 | ||
|
|
50f90ddcc2 | ||
|
|
c9bc7b81dd | ||
|
|
18a9ba3fb0 | ||
|
|
413571ee7f | ||
|
|
17fe3bb1f6 | ||
|
|
0e5adb246f | ||
|
|
296136203a | ||
|
|
1bbfebc2b8 | ||
|
|
d84e97b0d4 | ||
|
|
824aa8fa28 | ||
|
|
2b34ef3b7a | ||
|
|
4fa8f40e6a | ||
|
|
c1bddeb6ed | ||
|
|
5c2bfa0ea3 | ||
|
|
60e93965ea | ||
|
|
380d1fb930 | ||
|
|
53aa13c8aa | ||
|
|
13f5190ba1 | ||
|
|
c590e290e0 | ||
|
|
cdf86a2613 | ||
|
|
8726e22b38 | ||
|
|
1206486016 | ||
|
|
11ad281d72 | ||
|
|
fe4bfa1699 | ||
|
|
c6a0636e8c | ||
|
|
d3db6492d9 | ||
|
|
8f8617d8f9 | ||
|
|
8e513404c3 | ||
|
|
5a6dd172b1 | ||
|
|
fa0d7cac31 | ||
|
|
432b2ae185 | ||
|
|
fb8a6581dd | ||
|
|
a4f221f868 | ||
|
|
602d012efe | ||
|
|
5bf816eba2 | ||
|
|
a33c9d3517 | ||
|
|
1553f5ced2 | ||
|
|
41901b2174 | ||
|
|
177e4c1ff7 | ||
|
|
10036500cb | ||
|
|
a1fa777f8c | ||
|
|
472925bb05 | ||
|
|
8eb11efb34 | ||
|
|
48066a4018 | ||
|
|
5c8850ea8f | ||
|
|
09aea3cff5 | ||
|
|
45c5a890b9 | ||
|
|
69a3e85cb3 | ||
|
|
224439f62b | ||
|
|
2389ad5fdc | ||
|
|
4019623d99 | ||
|
|
57c17ffbf9 | ||
|
|
98d2ccfc86 | ||
|
|
c74a81cfdb | ||
|
|
3ebcf4a981 | ||
|
|
5d45027776 | ||
|
|
21ea8309c7 | ||
|
|
431331174a | ||
|
|
c26cfc038d | ||
|
|
39b7b34bb7 | ||
|
|
a4cf65e7c2 | ||
|
|
37668393f1 | ||
|
|
4309f734b6 | ||
|
|
b4957bae1f | ||
|
|
7a3b19bf7b | ||
|
|
1931373515 | ||
|
|
28939d1733 | ||
|
|
e6d35bc635 | ||
|
|
cc315a190a | ||
|
|
0d207d471c | ||
|
|
f2eb7a90ad | ||
|
|
c29ed9669e | ||
|
|
aced6077bd | ||
|
|
abe4d11498 | ||
|
|
91e50efb1a | ||
|
|
2914c54a47 | ||
|
|
d1701eff20 | ||
|
|
d4eb237e40 | ||
|
|
f4b2458417 | ||
|
|
c89e7e48ee | ||
|
|
5a3207f878 | ||
|
|
3d4afb40bc | ||
|
|
bf91187c1f | ||
|
|
963328e064 | ||
|
|
53227c7050 | ||
|
|
fe28cd95bd | ||
|
|
0f212828a7 | ||
|
|
bfb7d7915f | ||
|
|
92d49c306b | ||
|
|
b2df8ae320 | ||
|
|
98687bd78b | ||
|
|
970115d059 | ||
|
|
4893ebd932 | ||
|
|
3455eb701f | ||
|
|
c97c685149 | ||
|
|
0912948b31 | ||
|
|
4830f0b236 | ||
|
|
917e49b25d | ||
|
|
fe8c2fd2c6 | ||
|
|
c4a7ef8867 | ||
|
|
bac70b19ec | ||
|
|
08e3a66ece | ||
|
|
eda18f8c34 | ||
|
|
c85502e427 | ||
|
|
5626579b3f | ||
|
|
414dd50a5c | ||
|
|
ab61bfb2cd | ||
|
|
c05bb54976 | ||
|
|
2d53019c10 | ||
|
|
6e28bcdb96 | ||
|
|
adca37223c | ||
|
|
823b203b73 | ||
|
|
6c6f50444e | ||
|
|
c42c78fc98 | ||
|
|
33fd7512e7 | ||
|
|
3b02b3f554 | ||
|
|
a02577bb55 | ||
|
|
f8753eca90 | ||
|
|
4a0f2c9a67 | ||
|
|
9e5f15e9d5 | ||
|
|
bb089bb259 | ||
|
|
36b2acba6a | ||
|
|
0cfc3a48d8 | ||
|
|
f2fc41018d | ||
|
|
8024c09642 | ||
|
|
e6d8f084ae | ||
|
|
d63690e9d0 | ||
|
|
edf56bc97b | ||
|
|
d1d0a462f4 | ||
|
|
e6c6793f6e | ||
|
|
9c7b58ee99 | ||
|
|
ed759086c9 | ||
|
|
aa2a9851c3 | ||
|
|
02ff9e3b68 | ||
|
|
71338b3b07 | ||
|
|
373a0f0608 | ||
|
|
4c7826bbb3 | ||
|
|
ac50cd1373 | ||
|
|
2e47415160 | ||
|
|
3432005ade | ||
|
|
d10462cd4a | ||
|
|
ae1e84655a | ||
|
|
babcd8698e | ||
|
|
a85bcf917b | ||
|
|
1ddcbf1654 | ||
|
|
e97d0281e2 | ||
|
|
9941305998 | ||
|
|
a898e3013f | ||
|
|
c80d554630 | ||
|
|
996ba3f82d | ||
|
|
aca17f104e | ||
|
|
a1a5544789 | ||
|
|
a17efcf4d3 | ||
|
|
15514887a4 | ||
|
|
343e1a12d6 | ||
|
|
f52ea04541 | ||
|
|
89dd4f5c9d | ||
|
|
e30ca0ff82 | ||
|
|
8d761caf3e | ||
|
|
a4e03a59eb | ||
|
|
1362dfadda | ||
|
|
7a245cd375 | ||
|
|
a1b93cdb72 | ||
|
|
d30be10568 | ||
|
|
515fda5e97 | ||
|
|
22a678ec08 | ||
|
|
71c4f3db22 | ||
|
|
13ca8a2c54 | ||
|
|
f0fb1bee1e | ||
|
|
b66e11433f | ||
|
|
6d20f84489 | ||
|
|
20a8ce9cba | ||
|
|
17d2a8cb56 | ||
|
|
64cd17389d | ||
|
|
8f4cf7e948 | ||
|
|
bbfdb139c6 | ||
|
|
a80477b40e | ||
|
|
29d40ed406 | ||
|
|
22c1eaa541 | ||
|
|
6307e8d1e4 | ||
|
|
12bfa2fca1 | ||
|
|
100204b267 | ||
|
|
7d38fa5d85 | ||
|
|
5606dcb32f | ||
|
|
b3b790588a | ||
|
|
4f41022b30 | ||
|
|
9a09a04a5d | ||
|
|
5ce9fa515a | ||
|
|
abc53a0d9a | ||
|
|
e0a14ce6cf | ||
|
|
f154d8f5f4 | ||
|
|
1f18d8bb44 | ||
|
|
41460436df | ||
|
|
339783a1a4 | ||
|
|
c664b3e4a4 |
49
.eslintrc.js
@@ -1,49 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
'import/resolver': {
|
||||
node: {
|
||||
paths: ['src'],
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
amd: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'prettier'
|
||||
],
|
||||
plugins: [],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'jsx-a11y/accessible-emoji': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'jsx-a11y/anchor-is-valid': [
|
||||
'error',
|
||||
{
|
||||
components: ['Link'],
|
||||
specialLink: ['hrefLeft', 'hrefRight'],
|
||||
aspects: ['invalidHref', 'preferButton'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
88
.github/workflows/main.yml
vendored
@@ -1,77 +1,71 @@
|
||||
name: 'publish'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
name: "Publish"
|
||||
on: workflow_dispatch
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: short
|
||||
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
|
||||
RUSTFLAGS: "-W unreachable-pub -W rust-2021-compatibility"
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target universal-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'ubuntu-20.04'
|
||||
args: ''
|
||||
include:
|
||||
- platform: "macos-latest" # for Arm based macs (M1 and above).
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel based macs.
|
||||
args: "--target x86_64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel & Arm based macs.
|
||||
args: "--target universal-apple-darwin"
|
||||
- platform: 'windows-latest'
|
||||
args: '--target x86_64-pc-windows-msvc'
|
||||
- platform: 'windows-latest'
|
||||
args: '--target i686-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.settings.platform == 'ubuntu-20.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: Install pnpm
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install PNPM
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7.x.x
|
||||
version: 8.x.x
|
||||
run_install: false
|
||||
- name: Setup node and cache for package data
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-on-failure: true
|
||||
- run: pnpm install
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- uses: tauri-apps/tauri-action@dev
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
|
||||
releaseName: 'App v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
tagName: v__VERSION__
|
||||
releaseName: "v__VERSION__"
|
||||
releaseBody: "See the assets to download this version and install."
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.settings.args }}
|
||||
args: ${{ matrix.args }}
|
||||
includeDebug: false
|
||||
|
||||
10
.gitignore
vendored
@@ -10,13 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
out
|
||||
*.local
|
||||
.next
|
||||
.vscode
|
||||
pnpm-lock.yaml
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -28,4 +22,6 @@ pnpm-lock.yaml
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/.gtm/
|
||||
|
||||
src/routes.gen.ts
|
||||
src/commands.gen.ts
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm exec lint-staged
|
||||
@@ -1,9 +0,0 @@
|
||||
.tmp
|
||||
.cache/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
**/.yarn/**
|
||||
**/.pnp.*
|
||||
/dist*/
|
||||
node_modules/
|
||||
src-tauri/
|
||||
22
.prettierrc
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 90,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"importOrder": [
|
||||
"^@app/(.*)$",
|
||||
"^@libs/(.*)$",
|
||||
"^@shared/(.*)$",
|
||||
"^@stores/(.*)$",
|
||||
"^@utils/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
|
||||
"pluginSearchDirs": false
|
||||
}
|
||||
46
README.md
@@ -1,41 +1,33 @@
|
||||
### Introduction
|
||||
## Introduction
|
||||
|
||||
Lume is a nostr client
|
||||
Lume is a Nostr client for macOS and Windows 11. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||
|
||||
### Usage
|
||||
## Installation and Usage
|
||||
|
||||
Download Lume for your platform here: [https://github.com/luminous-devs/lume/releases](https://github.com/luminous-devs/lume/releases)
|
||||
- *Microsoft Windows*: See the releases area for a file named something like Lume_VERSION_x64-setup.exe or Lume_VERSION_x64_en-US.msi
|
||||
|
||||
Supported platform: macOS, Windows and Linux
|
||||
- *macOS*: See the releases area for a file named something like Lume_VERSION_PLATFORM.dmg
|
||||
|
||||
### Develop
|
||||
Lume only supported macOS and Windows 11. Linux user can consider using [Gossip client](https://github.com/mikedilger/gossip)
|
||||
|
||||
Clone project
|
||||
## Screenshots
|
||||
|
||||
```
|
||||
git clone https://github.com/luminous-devs/lume.git && cd lume
|
||||
```
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Install packages
|
||||
## Building from Source
|
||||
|
||||
```
|
||||
pnpm install
|
||||
```
|
||||
See [Developing](docs/DEVELOPING.md)
|
||||
|
||||
Run dev
|
||||
## License
|
||||
|
||||
```
|
||||
pnpm tauri dev
|
||||
```
|
||||
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors
|
||||
|
||||
Build
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
```
|
||||
pnpm tauri build
|
||||
```
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
(Advance) - Generate SQLite migration
|
||||
|
||||
```
|
||||
pnpm add-migrate <migrate_name>
|
||||
```
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
28
biome.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["./src/routes.gen.ts", "./src/commands.gen.ts"]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "warn",
|
||||
"noUselessElse": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"noSvgWithoutTitle": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
docs/DEVELOPING.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Developing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 20: https://nodejs.org/
|
||||
|
||||
- Rust: https://rustup.rs/
|
||||
|
||||
- PNPM: https://pnpm.io/
|
||||
|
||||
- Tauri: https://tauri.app/guides/prerequisites/
|
||||
|
||||
## Build from source
|
||||
|
||||
Clone project
|
||||
|
||||
```
|
||||
git clone https://github.com/lumehq/lume.git && cd lume
|
||||
```
|
||||
|
||||
Install required dependencies
|
||||
|
||||
```
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Run dev
|
||||
|
||||
```
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
Build
|
||||
|
||||
```
|
||||
pnpm tauri build
|
||||
```
|
||||
23
index.html
@@ -1,11 +1,14 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lume</title>
|
||||
</head>
|
||||
<body class="cursor-default select-none overflow-hidden font-sans antialiased h-screen w-screen dark:bg-black dark:text-zinc-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lume Desktop</title>
|
||||
</head>
|
||||
<body
|
||||
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
|
||||
>
|
||||
<div id="root" class="h-full w-full"></div>
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
165
package.json
@@ -1,84 +1,85 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri",
|
||||
"add-migrate": "cd src-tauri/ && sqlx migrate add",
|
||||
"prepare": "husky install",
|
||||
"lint": "eslint ./src --fix",
|
||||
"format": "prettier ./src --write"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{ts, tsx}": "eslint --fix",
|
||||
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@nostr-dev-kit/ndk": "^0.7.5",
|
||||
"@radix-ui/react-popover": "^1.0.6",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@tanstack/react-query": "^4.29.19",
|
||||
"@tanstack/react-query-devtools": "^4.29.19",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||
"@tauri-apps/api": "^1.4.0",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"dayjs": "^1.11.9",
|
||||
"destr": "^1.2.2",
|
||||
"framer-motion": "^10.12.18",
|
||||
"get-urls": "^11.0.0",
|
||||
"immer": "^10.0.2",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"nostr-tools": "^1.12.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.45.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-player": "^2.12.0",
|
||||
"react-router-dom": "^6.14.1",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-virtuoso": "^4.3.11",
|
||||
"slate": "^0.94.1",
|
||||
"slate-history": "^0.93.0",
|
||||
"slate-react": "^0.94.2",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
|
||||
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
|
||||
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
|
||||
"zustand": "^4.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tauri-apps/cli": "^1.4.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
||||
"@types/node": "^18.16.19",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/youtube-player": "^5.5.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||
"@typescript-eslint/parser": "^5.61.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"cross-env": "^7.0.3",
|
||||
"csstype": "^3.1.2",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^13.2.3",
|
||||
"postcss": "^8.4.25",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^4.4.2",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-tsconfig-paths": "^4.2.0"
|
||||
}
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getalby/bitcoin-connect-react": "^3.6.2",
|
||||
"@phosphor-icons/react": "^2.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tanstack/query-broadcast-client-experimental": "^5.59.16",
|
||||
"@tanstack/query-persist-client-core": "^5.59.16",
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"@tanstack/react-router": "^1.77.5",
|
||||
"@tauri-apps/api": "^2.0.3",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.1",
|
||||
"@tauri-apps/plugin-fs": "^2.0.1",
|
||||
"@tauri-apps/plugin-http": "^2.0.1",
|
||||
"@tauri-apps/plugin-os": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.1",
|
||||
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#a564510",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"@tauri-apps/plugin-upload": "^2.0.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.0.0",
|
||||
"bitcoin-units": "^1.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"i18next": "^23.16.4",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"light-bolt11-decoder": "^3.2.0",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nostr-tools": "^2.9.4",
|
||||
"react": "19.0.0-rc-cae764ce-20241025",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "19.0.0-rc-cae764ce-20241025",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-i18next": "^15.1.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"rich-textarea": "^0.26.3",
|
||||
"use-debounce": "^10.0.4",
|
||||
"virtua": "^0.34.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/router-devtools": "^1.77.5",
|
||||
"@tanstack/router-plugin": "^1.76.4",
|
||||
"@tauri-apps/cli": "^2.0.4",
|
||||
"@types/react": "npm:types-react@19.0.0-rc.1",
|
||||
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "0.0.0-experimental-b4db8c3-20241001",
|
||||
"clsx": "^2.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwind-gradient-mask-image": "^1.2.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-content-visibility": "^1.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-tsconfig-paths": "^5.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@rc",
|
||||
"@types/react-dom": "npm:types-react-dom@rc"
|
||||
}
|
||||
}
|
||||
|
||||
8927
pnpm-lock.yaml
generated
0
public/.keep
Normal file
BIN
public/404.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/nosta.jpg
Normal file
|
After Width: | Height: | Size: 191 KiB |
3
public/nsec_app.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.2711 21.2958C27.1084 21.2958 26.0749 21.8372 25.4037 22.6797L21.8702 20.6405C22.2577 19.8106 22.4755 18.8846 22.4755 17.908C22.4755 16.9314 22.2577 16.0053 21.8702 15.1755L25.3404 13.1742C26.0091 14.0648 27.0704 14.6442 28.2711 14.6442C30.2949 14.6442 31.9363 13.0047 31.9363 10.9831C31.9363 8.96158 30.2949 7.32208 28.2711 7.32208C26.2472 7.32208 24.6058 8.96158 24.6058 10.9831C24.6058 11.4006 24.6793 11.8003 24.8084 12.1748L21.3028 14.1963C20.2338 12.6732 18.5241 11.6333 16.5635 11.4638V7.274C18.3189 7.00076 19.6639 5.49029 19.6639 3.66104C19.6639 1.6395 18.0225 0 15.9987 0C13.9748 0 12.3334 1.6395 12.3334 3.66104C12.3334 5.49029 13.6784 7.00329 15.4338 7.274V11.4638C13.4733 11.6333 11.7635 12.6732 10.6946 14.1963L7.1889 12.1748C7.31808 11.8003 7.39154 11.4006 7.39154 10.9831C7.39154 8.96158 5.75015 7.32208 3.72629 7.32208C1.70242 7.32208 0.0610352 8.96158 0.0610352 10.9831C0.0610352 13.0047 1.70242 14.6442 3.72629 14.6442C4.92693 14.6442 5.98825 14.0648 6.65697 13.1742L10.1272 15.1755C9.73963 16.0053 9.52179 16.9314 9.52179 17.908C9.52179 18.8846 9.73963 19.8106 10.1272 20.643L6.59364 22.6822C5.9224 21.8397 4.88893 21.2983 3.72629 21.2983C1.70242 21.2983 0.0610352 22.9378 0.0610352 24.9593C0.0610352 26.9809 1.70242 28.6204 3.72629 28.6204C5.75015 28.6204 7.39154 26.9809 7.39154 24.9593C7.39154 24.5039 7.30542 24.0687 7.1509 23.6639L10.6946 21.6196C11.3329 22.5279 12.1992 23.2667 13.2098 23.7499V32.3497C13.2098 32.9721 13.5569 33.8551 13.9799 34.3131L15.2286 35.6565C15.6516 36.1145 16.3457 36.1145 16.7712 35.6565L18.02 34.3131C18.443 33.8551 18.79 32.9721 18.79 32.3497V23.7499C19.8007 23.2667 20.667 22.5304 21.3053 21.6196L24.849 23.6639C24.697 24.0662 24.6083 24.5014 24.6083 24.9593C24.6083 26.9809 26.2497 28.6204 28.2736 28.6204C30.2975 28.6204 31.9388 26.9809 31.9388 24.9593C31.9388 22.9378 30.2975 21.2983 28.2736 21.2983L28.2711 21.2958Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
7
src-tauri/.gitignore
vendored
@@ -1,3 +1,10 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
|
||||
# Config
|
||||
.cargo
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
tab_spaces=2
|
||||
6455
src-tauri/Cargo.lock
generated
@@ -1,51 +1,69 @@
|
||||
[package]
|
||||
name = "lume"
|
||||
version = "1.0.1"
|
||||
name = "Lume"
|
||||
version = "24.11.0"
|
||||
description = "nostr client"
|
||||
authors = ["Ren Amamiya"]
|
||||
license = ""
|
||||
repository = ""
|
||||
authors = ["npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445"]
|
||||
repository = "https://github.com/lumehq/lume"
|
||||
edition = "2021"
|
||||
rust-version = "1.57"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
rust-version = "1.70"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.2", features = [] }
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0.0", features = [ "protocol-asset",
|
||||
"unstable",
|
||||
"tray-icon",
|
||||
"macos-private-api"
|
||||
] }
|
||||
tauri-plugin-window-state = "2.0.0"
|
||||
tauri-plugin-clipboard-manager = "2.0.0"
|
||||
tauri-plugin-dialog = "2.0.0"
|
||||
tauri-plugin-fs = "2.0.0"
|
||||
tauri-plugin-http = "2.0.0"
|
||||
tauri-plugin-notification = "2.0.0"
|
||||
tauri-plugin-os = "2.0.0"
|
||||
tauri-plugin-process = "2.0.0"
|
||||
tauri-plugin-shell = "2.0.0"
|
||||
tauri-plugin-updater = "2.0.0"
|
||||
tauri-plugin-upload = "2.0.0"
|
||||
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", rev = "8c67d44" }
|
||||
tauri-plugin-prevent-default = "0.6"
|
||||
tauri-plugin-theme = "2.1.2"
|
||||
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum" }
|
||||
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
|
||||
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb", "webln", "all-nips"] }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
specta = "^2.0.0-rc.20"
|
||||
specta-typescript = "0.0.7"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = [ "path-all", "fs-read-dir", "fs-read-file", "clipboard-read-text", "clipboard-write-text", "dialog-open", "http-all", "http-multipart", "notification-all", "os-all", "process-relaunch", "shell-open", "system-tray", "updater", "window-close", "window-start-dragging"] }
|
||||
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
sqlx-cli = {version = "0.7.0", default-features = false, features = ["sqlite"] }
|
||||
rust-argon2 = "1.0"
|
||||
rand = "0.8.5"
|
||||
|
||||
[dependencies.tauri-plugin-sql]
|
||||
git = "https://github.com/tauri-apps/plugins-workspace"
|
||||
branch = "v1"
|
||||
features = ["sqlite"]
|
||||
reqwest = "0.12.4"
|
||||
url = "2.5.0"
|
||||
futures = "0.3.30"
|
||||
linkify = "0.10.0"
|
||||
regex = "1.10.4"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native"] }
|
||||
keyring-search = { git = "https://github.com/reyamir/keyring-search" }
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
async-trait = "0.1.83"
|
||||
webbrowser = "1.0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2.7"
|
||||
cocoa = "0.24.1"
|
||||
border = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
||||
share-picker = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||
default = ["custom-protocol"]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
# Optimized for bundle size. If you want faster builds comment out/delete this section.
|
||||
[profile.release]
|
||||
lto = true # Enable Link Time Optimization
|
||||
opt-level = "z" # Optimize for size.
|
||||
codegen-units = 1 # Reduce number of codegen units to increase optimizations.
|
||||
panic = "abort" # Abort on panic
|
||||
strip = true # Automatically strip symbols from the binary.
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
panic = "abort"
|
||||
incremental = false
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
rpath = false
|
||||
debug = false
|
||||
debug-assertions = false
|
||||
overflow-checks = false
|
||||
|
||||
80
src-tauri/capabilities/main.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "window",
|
||||
"description": "Capability for the desktop",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows"
|
||||
],
|
||||
"windows": [
|
||||
"*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:path:default",
|
||||
"core:event:default",
|
||||
"core:window:default",
|
||||
"core:app:default",
|
||||
"core:resources:default",
|
||||
"core:menu:default",
|
||||
"core:tray:default",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-center",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:webview:allow-create-webview",
|
||||
"core:webview:allow-set-webview-size",
|
||||
"core:webview:allow-set-webview-position",
|
||||
"core:webview:allow-webview-close",
|
||||
"core:menu:allow-new",
|
||||
"core:menu:allow-popup",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:default",
|
||||
"os:allow-locale",
|
||||
"os:allow-platform",
|
||||
"os:allow-os-type",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download-and-install",
|
||||
"decorum:allow-show-snap-overlay",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"dialog:default",
|
||||
"process:allow-restart",
|
||||
"process:allow-exit",
|
||||
"fs:allow-read-file",
|
||||
"shell:allow-open",
|
||||
"store:default",
|
||||
"prevent-default:default",
|
||||
"theme:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{
|
||||
"url": "http://**/"
|
||||
},
|
||||
{
|
||||
"url": "https://**/"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-text-file",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$RESOURCE/locales/*"
|
||||
},
|
||||
{
|
||||
"path": "$RESOURCE/resources/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"window":{"identifier":"window","description":"Capability for the desktop","local":true,"windows":["*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","core:menu:allow-new","core:menu:allow-popup","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:default","process:allow-restart","process:allow-exit","fs:allow-read-file","shell:allow-open","store:default","prevent-default:default","theme:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}
|
||||
5853
src-tauri/gen/schemas/desktop-schema.json
Normal file
5853
src-tauri/gen/schemas/macOS-schema.json
Normal file
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 447 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 88 KiB |
BIN
src-tauri/icons/tray.png
Normal file
|
After Width: | Height: | Size: 620 B |
295
src-tauri/locales/cn.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"global": {
|
||||
"relay": "Relay",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"loading": "Loading",
|
||||
"error": "Error",
|
||||
"moveLeft": "Move Left",
|
||||
"moveRight": "Move Right",
|
||||
"newColumn": "New Column",
|
||||
"inspect": "Inspect",
|
||||
"loadMore": "Load more",
|
||||
"delete": "Delete",
|
||||
"refresh": "Refresh",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"post": "Post",
|
||||
"update": "Update",
|
||||
"noResult": "No results found.",
|
||||
"emptyFeedTitle": "This feed is empty",
|
||||
"emptyFeedSubtitle": "You can follow more users to build up your timeline",
|
||||
"apiKey": "API Key",
|
||||
"skip": "Skip",
|
||||
"close": "Close"
|
||||
},
|
||||
"nip89": {
|
||||
"unsupported": "Lume isn't support this event",
|
||||
"openWith": "Open with"
|
||||
},
|
||||
"note": {
|
||||
"showThread": "Show thread",
|
||||
"showMore": "Show more",
|
||||
"error": "Failed to fetch event.",
|
||||
"posted": "posted",
|
||||
"replied": "replied",
|
||||
"reposted": "reposted",
|
||||
"menu": {
|
||||
"viewThread": "View thread",
|
||||
"copyLink": "Copy shareable link",
|
||||
"copyNoteId": "Copy note ID",
|
||||
"copyAuthorId": "Copy author ID",
|
||||
"viewAuthor": "View author",
|
||||
"pinAuthor": "Pin author",
|
||||
"copyRaw": "Copy raw event",
|
||||
"mute": "Mute"
|
||||
},
|
||||
"buttons": {
|
||||
"pin": "Pin",
|
||||
"pinTooltip": "Pin Note",
|
||||
"repost": "Repost",
|
||||
"quote": "Quote",
|
||||
"viewProfile": "View profile"
|
||||
},
|
||||
"zap": {
|
||||
"zap": "Zap",
|
||||
"tooltip": "Send zap",
|
||||
"modalTitle": "Send zap to",
|
||||
"messagePlaceholder": "Enter message (optional)",
|
||||
"buttonFinish": "Zapped",
|
||||
"buttonLoading": "Processing...",
|
||||
"invoiceButton": "Scan to zap",
|
||||
"invoiceFooter": "You must use Bitcoin wallet which support Lightning\nsuch as: Blue Wallet, Bitkit, Phoenix,..."
|
||||
},
|
||||
"reply": {
|
||||
"single": "reply",
|
||||
"plural": "replies",
|
||||
"empty": "Be the first to Reply!"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"avatar": "Avatar",
|
||||
"displayName": "Display Name",
|
||||
"name": "Name",
|
||||
"bio": "Bio",
|
||||
"lna": "Lightning address",
|
||||
"website": "Website",
|
||||
"verified": "Verified",
|
||||
"unverified": "Unverified",
|
||||
"follow": "Follow",
|
||||
"unfollow": "Unfollow",
|
||||
"latestPosts": "Latest posts",
|
||||
"avatarButton": "Change avatar",
|
||||
"coverButton": "Change cover",
|
||||
"editProfile": "Edit profile",
|
||||
"settings": "Settings",
|
||||
"logout": "Log out",
|
||||
"logoutConfirmTitle": "Are you sure!",
|
||||
"logoutConfirmSubtitle": "You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account."
|
||||
},
|
||||
"editor": {
|
||||
"title": "New Post",
|
||||
"placeholder": "What are you up to?",
|
||||
"successMessage": "Your note has been published successfully.",
|
||||
"replyPlaceholder": "Post your reply"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Type something to search...",
|
||||
"empty": "Try searching for people, notes, or keywords"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Lume is a magnificent client for Nostr to meet, explore\nand freely share your thoughts with everyone.",
|
||||
"signup": "Join Nostr",
|
||||
"login": "Login",
|
||||
"footer": "Before joining Nostr, you can take time to learn more about Nostr"
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome back, anon!",
|
||||
"footer": "Lume will put your Private Key in Secure Storage depended on your OS Platform. It will be secured by Password or Biometric ID",
|
||||
"loginWithAddress": "Login with Nostr Address",
|
||||
"loginWithBunker": "Login with nsecBunker",
|
||||
"or": "Or continue with",
|
||||
"loginWithPrivkey": "Login with Private Key"
|
||||
},
|
||||
"loginWithAddress": {
|
||||
"title": "Enter your Nostr Address"
|
||||
},
|
||||
"loginWithBunker": {
|
||||
"title": "Enter your nsecbunker token"
|
||||
},
|
||||
"loginWithPrivkey": {
|
||||
"title": "Enter your Private Key",
|
||||
"subtitle": "Lume will put your private key to <1>{{service}}</1>.\nIt will be secured by your OS."
|
||||
},
|
||||
"signup": {
|
||||
"title": "Let's Get Started",
|
||||
"subtitle": "Choose one of methods below to create your account",
|
||||
"selfManageMethod": "Self-Managed",
|
||||
"selfManageMethodDescription": "You create your keys and keep them safe.",
|
||||
"providerMethod": "Managed by Provider",
|
||||
"providerMethodDescription": "A 3rd party provider will handle your sign in keys for you."
|
||||
},
|
||||
"signupWithSelfManage": {
|
||||
"title": "This is your new Account Key",
|
||||
"subtitle": "Keep your key in safe place. If you lose this key, you will lose access to your account.",
|
||||
"confirm1": "I understand the risk of lost private key.",
|
||||
"confirm2": "I will make sure keep it safe and not sharing with anyone.",
|
||||
"confirm3": "I understand I cannot recover private key.",
|
||||
"button": "Save key & Continue"
|
||||
},
|
||||
"signupWithProvider": {
|
||||
"title": "Let's set up your account on Nostr",
|
||||
"username": "Username *",
|
||||
"chooseProvider": "Choose a Provider",
|
||||
"usernameFooter": "Use to login to Lume and other Nostr apps. You can choose provider you trust to manage your account",
|
||||
"email": "Backup Email (optional)",
|
||||
"emailFooter": "Use for recover your account if you lose your password"
|
||||
},
|
||||
"onboardingSettings": {
|
||||
"title": "You're almost ready to use Lume.",
|
||||
"subtitle": "Let's start personalizing your experience.",
|
||||
"notification": {
|
||||
"title": "Push notification",
|
||||
"subtitle": "Enabling push notifications will allow you to receive notifications from Lume."
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Low Power Mode",
|
||||
"subtitle": "Limited relay connection and hide all media, sustainable for low network environment."
|
||||
},
|
||||
"translation": {
|
||||
"title": "Translation (nostr.wine)",
|
||||
"subtitle": "Translate text to your preferred language, powered by Nostr Wine."
|
||||
},
|
||||
"footer": "There are many more settings you can configure from the 'Settings' Screen. Be sure to visit it later."
|
||||
},
|
||||
"relays": {
|
||||
"global": "Global",
|
||||
"follows": "Follows",
|
||||
"sidebar": {
|
||||
"title": "Connected relays",
|
||||
"empty": "Empty."
|
||||
},
|
||||
"relayView": {
|
||||
"empty": "Could not load relay information 😬",
|
||||
"owner": "Owner",
|
||||
"contact": "Contact",
|
||||
"software": "Software",
|
||||
"nips": "Supported NIPs",
|
||||
"limit": "Limitation",
|
||||
"payment": "Open payment website",
|
||||
"paymentNote": "You need to make a payment to connect this relay"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"title": "Suggested Follows",
|
||||
"error": "Error. Cannot get trending users",
|
||||
"button": "Save & Go back"
|
||||
},
|
||||
"interests": {
|
||||
"title": "Interests",
|
||||
"subtitle": "Pick things you'd like to see in your home feed.",
|
||||
"edit": "Edit Interest",
|
||||
"followAll": "Follow All",
|
||||
"unfollowAll": "Unfollow All"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"update": {
|
||||
"title": "Update",
|
||||
"subtitle": "Automatically download new update"
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Low Power",
|
||||
"subtitle": "Sustainable for low network environment"
|
||||
},
|
||||
"startup": {
|
||||
"title": "Startup",
|
||||
"subtitle": "Launch Lume at Login"
|
||||
},
|
||||
"media": {
|
||||
"title": "Media",
|
||||
"subtitle": "Automatically load media"
|
||||
},
|
||||
"hashtag": {
|
||||
"title": "Hashtag",
|
||||
"subtitle": "Show all hashtags in content"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notification",
|
||||
"subtitle": "Automatically send notification"
|
||||
},
|
||||
"translation": {
|
||||
"title": "Translation",
|
||||
"subtitle": "Translate text to your language"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "User"
|
||||
},
|
||||
"zap": {
|
||||
"title": "Zap",
|
||||
"nwc": "Connection String"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
"privkey": {
|
||||
"title": "Private key",
|
||||
"button": "Remove private key"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"cache": {
|
||||
"title": "Cache",
|
||||
"subtitle": "Use for boost up nostr connection",
|
||||
"button": "Clear"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Instant Zap",
|
||||
"subtitle": "Zap with default amount, no confirmation"
|
||||
},
|
||||
"defaultAmount": "Default amount"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"version": "Version",
|
||||
"checkUpdate": "Check for update",
|
||||
"installUpdate": "Install"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"home": {
|
||||
"title": "Your account was successfully created!",
|
||||
"subtitle": "For starters, let's set up your profile.",
|
||||
"profileSettings": "Profile Settings"
|
||||
},
|
||||
"profile": {
|
||||
"title": "About you",
|
||||
"subtitle": "Tell Lume about yourself to start building your home feed."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Profile setup complete!",
|
||||
"subtitle": "You can exit the setup here and start using Lume.",
|
||||
"report": "Report a issue"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity",
|
||||
"empty": "Yo! Nothing new yet.",
|
||||
"mention": "mention you",
|
||||
"repost": "reposted",
|
||||
"zap": "zapped",
|
||||
"newReply": "New reply",
|
||||
"boost": "Boost",
|
||||
"boostSubtitle": "@ Someone has reposted to your note",
|
||||
"conversation": "Conversation",
|
||||
"conversationSubtitle": "@ Someone has replied to your note"
|
||||
}
|
||||
}
|
||||
298
src-tauri/locales/en.json
Normal file
@@ -0,0 +1,298 @@
|
||||
{
|
||||
"global": {
|
||||
"relay": "Relay",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"loading": "Loading",
|
||||
"error": "Error",
|
||||
"moveLeft": "Move Left",
|
||||
"moveRight": "Move Right",
|
||||
"newColumn": "New Column",
|
||||
"inspect": "Inspect",
|
||||
"loadMore": "Load more",
|
||||
"delete": "Delete",
|
||||
"refresh": "Refresh",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"post": "Post",
|
||||
"update": "Update",
|
||||
"noResult": "No results found.",
|
||||
"emptyFeedTitle": "This feed is empty",
|
||||
"emptyFeedSubtitle": "You can follow more users to build up your timeline",
|
||||
"apiKey": "API Key",
|
||||
"skip": "Skip",
|
||||
"close": "Close"
|
||||
},
|
||||
"nip89": {
|
||||
"unsupported": "Lume isn't support this event",
|
||||
"openWith": "Open with"
|
||||
},
|
||||
"note": {
|
||||
"showThread": "Show thread",
|
||||
"showMore": "Show more",
|
||||
"error": "Failed to fetch event.",
|
||||
"posted": "posted",
|
||||
"replied": "replied",
|
||||
"reposted": "reposted",
|
||||
"menu": {
|
||||
"viewThread": "View thread",
|
||||
"copyLink": "Copy shareable link",
|
||||
"copyNoteId": "Copy note ID",
|
||||
"copyAuthorId": "Copy author ID",
|
||||
"viewAuthor": "View author",
|
||||
"pinAuthor": "Pin author",
|
||||
"copyRaw": "Copy raw event",
|
||||
"mute": "Mute"
|
||||
},
|
||||
"buttons": {
|
||||
"pin": "Pin",
|
||||
"pinTooltip": "Pin Note",
|
||||
"repost": "Repost",
|
||||
"quote": "Quote",
|
||||
"viewProfile": "View profile",
|
||||
"reply": "Reply this note",
|
||||
"open": "Open in new window"
|
||||
},
|
||||
"zap": {
|
||||
"zap": "Zap",
|
||||
"tooltip": "Send zap",
|
||||
"modalTitle": "Send zap to",
|
||||
"messagePlaceholder": "Enter message (optional)",
|
||||
"buttonFinish": "Zapped",
|
||||
"buttonLoading": "Processing...",
|
||||
"invoiceButton": "Scan to zap",
|
||||
"invoiceFooter": "You must use Bitcoin wallet which support Lightning\nsuch as: Blue Wallet, Bitkit, Phoenix,..."
|
||||
},
|
||||
"reply": {
|
||||
"single": "reply",
|
||||
"plural": "replies",
|
||||
"empty": "Be the first to Reply!"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"avatar": "Avatar",
|
||||
"displayName": "Display Name",
|
||||
"name": "Name",
|
||||
"bio": "Bio",
|
||||
"lna": "Lightning address",
|
||||
"website": "Website",
|
||||
"verified": "Verified",
|
||||
"unverified": "Unverified",
|
||||
"follow": "Follow",
|
||||
"unfollow": "Unfollow",
|
||||
"latestPosts": "Latest posts",
|
||||
"avatarButton": "Change avatar",
|
||||
"coverButton": "Change cover",
|
||||
"editProfile": "Edit profile",
|
||||
"settings": "Settings",
|
||||
"logout": "Log out",
|
||||
"logoutConfirmTitle": "Are you sure!",
|
||||
"logoutConfirmSubtitle": "You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account."
|
||||
},
|
||||
"editor": {
|
||||
"title": "New Post",
|
||||
"placeholder": "What are you up to?",
|
||||
"successMessage": "Your note has been published successfully.",
|
||||
"replyPlaceholder": "Post your reply"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Type something to search...",
|
||||
"empty": "Try searching for people, notes, or keywords"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Lume is a magnificent client for Nostr to meet, explore\nand freely share your thoughts with everyone.",
|
||||
"signup": "Join Nostr",
|
||||
"login": "Login",
|
||||
"footer": "Before joining Nostr, you can take time to learn more about Nostr"
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome back, anon!",
|
||||
"subtitle": "We're so excited to see you again!",
|
||||
"footer": "Lume will put your Private Key in Secure Storage depended on your OS Platform. It will be secured by Password or Biometric ID",
|
||||
"loginWithAddress": "Login with Nostr Address",
|
||||
"loginWithBunker": "Login with nsecBunker",
|
||||
"or": "Or continue with",
|
||||
"loginWithPrivkey": "Login with Private Key"
|
||||
},
|
||||
"loginWithAddress": {
|
||||
"title": "Enter your Nostr Address"
|
||||
},
|
||||
"loginWithBunker": {
|
||||
"title": "Enter your nsecbunker token"
|
||||
},
|
||||
"loginWithPrivkey": {
|
||||
"title": "Enter your Private Key",
|
||||
"subtitle": "Lume will put your private key to <1>{{service}}</1>.\nIt will be secured by your OS."
|
||||
},
|
||||
"signup": {
|
||||
"title": "Let's Get Started",
|
||||
"subtitle": "Choose one of methods below to create your account",
|
||||
"selfManageMethod": "Self-Managed",
|
||||
"selfManageMethodDescription": "You create your keys and keep them safe.",
|
||||
"providerMethod": "Managed by Provider",
|
||||
"providerMethodDescription": "A 3rd party provider will handle your sign in keys for you."
|
||||
},
|
||||
"backup": {
|
||||
"title": "This is your new sign in key",
|
||||
"subtitle": "Keep your key in safe place. If you lose this key, you will lose access to your account.",
|
||||
"confirm1": "I understand the risk of lost private key.",
|
||||
"confirm2": "I will make sure keep it safe and not sharing with anyone.",
|
||||
"confirm3": "I understand I cannot recover private key.",
|
||||
"button": "Save & Continue"
|
||||
},
|
||||
"signupWithProvider": {
|
||||
"title": "Let's set up your account on Nostr",
|
||||
"username": "Username *",
|
||||
"chooseProvider": "Choose a Provider",
|
||||
"usernameFooter": "Use to login to Lume and other Nostr apps. You can choose provider you trust to manage your account",
|
||||
"email": "Backup Email (optional)",
|
||||
"emailFooter": "Use for recover your account if you lose your password"
|
||||
},
|
||||
"onboardingSettings": {
|
||||
"title": "You're almost ready to use Lume.",
|
||||
"subtitle": "Let's start personalizing your experience.",
|
||||
"notification": {
|
||||
"title": "Push notification",
|
||||
"subtitle": "Enabling push notifications will allow you to receive notifications from Lume."
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Low Power Mode",
|
||||
"subtitle": "Limited relay connection and hide all media, sustainable for low network environment."
|
||||
},
|
||||
"translation": {
|
||||
"title": "Translation (nostr.wine)",
|
||||
"subtitle": "Translate text to your preferred language, powered by Nostr Wine."
|
||||
},
|
||||
"footer": "There are many more settings you can configure from the 'Settings' Screen. Be sure to visit it later."
|
||||
},
|
||||
"relays": {
|
||||
"global": "Global",
|
||||
"follows": "Follows",
|
||||
"sidebar": {
|
||||
"title": "Connected relays",
|
||||
"empty": "Empty."
|
||||
},
|
||||
"relayView": {
|
||||
"empty": "Could not load relay information 😬",
|
||||
"owner": "Owner",
|
||||
"contact": "Contact",
|
||||
"software": "Software",
|
||||
"nips": "Supported NIPs",
|
||||
"limit": "Limitation",
|
||||
"payment": "Open payment website",
|
||||
"paymentNote": "You need to make a payment to connect this relay"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"title": "Suggested Follows",
|
||||
"error": "Error. Cannot get trending users",
|
||||
"button": "Save & Go back"
|
||||
},
|
||||
"interests": {
|
||||
"title": "Interests",
|
||||
"subtitle": "Pick things you'd like to see in your home feed.",
|
||||
"edit": "Edit Interest",
|
||||
"followAll": "Follow All",
|
||||
"unfollowAll": "Unfollow All"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"update": {
|
||||
"title": "Update",
|
||||
"subtitle": "Automatically download new update"
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Low Power",
|
||||
"subtitle": "Sustainable for low network environment"
|
||||
},
|
||||
"startup": {
|
||||
"title": "Startup",
|
||||
"subtitle": "Launch Lume at Login"
|
||||
},
|
||||
"media": {
|
||||
"title": "Media",
|
||||
"subtitle": "Automatically load media"
|
||||
},
|
||||
"hashtag": {
|
||||
"title": "Hashtag",
|
||||
"subtitle": "Show all hashtags in content"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notification",
|
||||
"subtitle": "Automatically send notification"
|
||||
},
|
||||
"translation": {
|
||||
"title": "Translation",
|
||||
"subtitle": "Translate text to your language"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "Account"
|
||||
},
|
||||
"zap": {
|
||||
"title": "Zap",
|
||||
"nwc": "Connection String"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
"privkey": {
|
||||
"title": "Private key",
|
||||
"button": "Remove private key"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"cache": {
|
||||
"title": "Cache",
|
||||
"subtitle": "Use for boost up nostr connection",
|
||||
"button": "Clear"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Instant Zap",
|
||||
"subtitle": "Zap with default amount, no confirmation"
|
||||
},
|
||||
"defaultAmount": "Default amount"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"version": "Version",
|
||||
"checkUpdate": "Check for update",
|
||||
"installUpdate": "Install"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"home": {
|
||||
"title": "Your account was successfully created!",
|
||||
"subtitle": "For starters, let's set up your profile.",
|
||||
"profileSettings": "Profile Settings"
|
||||
},
|
||||
"profile": {
|
||||
"title": "About you",
|
||||
"subtitle": "Tell Lume about yourself to start building your home feed."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Profile setup complete!",
|
||||
"subtitle": "You can exit the setup here and start using Lume.",
|
||||
"report": "Report a issue"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity",
|
||||
"empty": "Yo! Nothing new yet.",
|
||||
"mention": "mention you",
|
||||
"repost": "reposted",
|
||||
"zap": "zapped",
|
||||
"newReply": "New reply",
|
||||
"boost": "Boost",
|
||||
"boostSubtitle": "@ Someone has reposted to your note",
|
||||
"conversation": "Conversation",
|
||||
"conversationSubtitle": "@ Someone has replied to your note"
|
||||
}
|
||||
}
|
||||
295
src-tauri/locales/fr.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"global": {
|
||||
"relay": "Relais",
|
||||
"back": "Retour",
|
||||
"continue": "Continuer",
|
||||
"loading": "Chargement",
|
||||
"error": "Erreur",
|
||||
"moveLeft": "Déplacer à Gauche",
|
||||
"moveRight": "Déplacer à Droite",
|
||||
"newColumn": "Nouvelle Colonne",
|
||||
"inspect": "Inspecter",
|
||||
"loadMore": "Charger plus",
|
||||
"delete": "Supprimer",
|
||||
"refresh": "Rafraîchir",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"post": "Poster",
|
||||
"update": "Mettre à jour",
|
||||
"noResult": "Aucun résultat correspondant.",
|
||||
"emptyFeedTitle": "Ce flux est vide",
|
||||
"emptyFeedSubtitle": "Vous pouvez suivre plus d'utilisateurs pour construire votre flux de messages",
|
||||
"apiKey": "Clé d'API",
|
||||
"skip": "Passer",
|
||||
"close": "Fermer"
|
||||
},
|
||||
"nip89": {
|
||||
"unsupported": "Lume ne supporte pas encore cet événement",
|
||||
"openWith": "Ouvrir avec"
|
||||
},
|
||||
"note": {
|
||||
"showThread": "Voir le fil",
|
||||
"showMore": "Voir plus",
|
||||
"error": "Echec de la récupération de l'événement.",
|
||||
"posted": "posté",
|
||||
"replied": "répondu",
|
||||
"reposted": "reposté",
|
||||
"menu": {
|
||||
"viewThread": "Voir le fil",
|
||||
"copyLink": "Copier le lien à partager",
|
||||
"copyNoteId": "Copier l'ID de la note",
|
||||
"copyAuthorId": "Copier l'ID de l'auteur",
|
||||
"viewAuthor": "Voir l'auteur",
|
||||
"pinAuthor": "Epingler l'auteur",
|
||||
"copyRaw": "Copier le contenu brut de l'événement",
|
||||
"mute": "Mettre en silencieux"
|
||||
},
|
||||
"buttons": {
|
||||
"pin": "Epingler",
|
||||
"pinTooltip": "Epingler la Note",
|
||||
"repost": "Reposter",
|
||||
"quote": "Citer",
|
||||
"viewProfile": "Voir le profil"
|
||||
},
|
||||
"zap": {
|
||||
"zap": "Zap",
|
||||
"tooltip": "Envoyer zap",
|
||||
"modalTitle": "Envoyer zap à",
|
||||
"messagePlaceholder": "Entrer un message (optionnel)",
|
||||
"buttonFinish": "Zappé",
|
||||
"buttonLoading": "En cours...",
|
||||
"invoiceButton": "Scanner pour zapper",
|
||||
"invoiceFooter": "Vous devez utiliser un portefeuille Bitcoin qui prend en charge Lightning\ncomme par exemple : Blue Wallet, Bitkit, Phoenix, ..."
|
||||
},
|
||||
"reply": {
|
||||
"single": "réponse",
|
||||
"plural": "réponses",
|
||||
"empty": "Soyez le premier à répondre !"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"avatar": "Avatar",
|
||||
"displayName": "Nom Affiché",
|
||||
"name": "Nom",
|
||||
"bio": "Bio",
|
||||
"lna": "Adresse Lightning",
|
||||
"website": "Site Internet",
|
||||
"verified": "Vérifié",
|
||||
"unverified": "Non vérifié",
|
||||
"follow": "Suivre",
|
||||
"unfollow": "Ne plus suivre",
|
||||
"latestPosts": "Derniers posts",
|
||||
"avatarButton": "Changer d'avatar",
|
||||
"coverButton": "Changer de bannière",
|
||||
"editProfile": "Modifier profil",
|
||||
"settings": "Paramètres",
|
||||
"logout": "Déconnexion",
|
||||
"logoutConfirmTitle": "Etes-vous sûr ?",
|
||||
"logoutConfirmSubtitle": "Vous pouvez toujours vous reconnecter à tout moment. Si vous souhaitez simplement changer de compte, vous pouvez le faire en ajoutant un compte existant."
|
||||
},
|
||||
"editor": {
|
||||
"title": "Nouveau Post",
|
||||
"placeholder": "Que faites-vous ?",
|
||||
"successMessage": "Votre note a été publiée avec succès.",
|
||||
"replyPlaceholder": "Poster votre réponse"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Saisissez quelque chose à rechercher...",
|
||||
"empty": "Essayez de rechercher des personnes, des notes ou des mots-clés"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Lume est un magnifique client Nostr qui permet de se rencontrer, d'explorer\net de partager librement ses pensées avec tout le monde.",
|
||||
"signup": "Rejoindre Nostr",
|
||||
"login": "Se connecter",
|
||||
"footer": "Avant de rejoindre Nostr, vous pouvez prendre le temps d'en savoir plus sur Nostr"
|
||||
},
|
||||
"login": {
|
||||
"title": "Re-Bienvenue, anon !",
|
||||
"footer": "Lume placera votre clé privée dans un espace de stockage sécurisé en fonction de la plateforme de votre système d'exploitation. Elle sera sécurisée par un mot de passe ou un identifiant biométrique.",
|
||||
"loginWithAddress": "Se connecter avec une Adresse Nostr",
|
||||
"loginWithBunker": "Se connecter avec nsecBunker",
|
||||
"or": "Ou continuer avec",
|
||||
"loginWithPrivkey": "Se connecter avec une Clé Privée"
|
||||
},
|
||||
"loginWithAddress": {
|
||||
"title": "Saisissez votre Adresse Nostr"
|
||||
},
|
||||
"loginWithBunker": {
|
||||
"title": "Saisissez votre jeton nsecbunker"
|
||||
},
|
||||
"loginWithPrivkey": {
|
||||
"title": "Saisissez votre Clé Privée",
|
||||
"subtitle": "Lume placera votre clé privée dans <1>{{service}}</1>.\nElle sera sécurisée par votre système d'exploitation."
|
||||
},
|
||||
"signup": {
|
||||
"title": "Commençons",
|
||||
"subtitle": "Choisissez l'une des méthodes ci-dessous pour créer votre compte",
|
||||
"selfManageMethod": "Autogéré",
|
||||
"selfManageMethodDescription": "Vous créez vos clés et vous les conservez en toute sécurité.",
|
||||
"providerMethod": "Géré par le Prestataire",
|
||||
"providerMethodDescription": "Un fournisseur tiers s'occupera de vos clés de connexion pour vous."
|
||||
},
|
||||
"signupWithSelfManage": {
|
||||
"title": "Il s'agit de votre nouvelle Clé de Compte",
|
||||
"subtitle": "Conservez votre clé en lieu sûr. Si vous perdez cette clé, vous perdrez l'accès à votre compte.",
|
||||
"confirm1": "Je comprends le risque de perte de la clé privée.",
|
||||
"confirm2": "Je veillerai à ce qu'elle soit conservée en toute sécurité et à ce qu'elle ne soit partagée avec personne.",
|
||||
"confirm3": "Je comprends que je ne peux pas récupérer la clé privée.",
|
||||
"button": "Enregistrer la clé et Continuer"
|
||||
},
|
||||
"signupWithProvider": {
|
||||
"title": "Commençons la préparation de votre compte Nostr",
|
||||
"username": "Nom d'utilisateur *",
|
||||
"chooseProvider": "Choisissez un Fournisseur",
|
||||
"usernameFooter": "A utiliser pour vous connecter à Lume et aux autres applications Nostr. Vous pouvez choisir un fournisseur de confiance pour gérer votre compte.",
|
||||
"email": "Email de sauvegarde (optionnel)",
|
||||
"emailFooter": "A utiliser pour récupérer votre compte si vous perdez votre mot de passe"
|
||||
},
|
||||
"onboardingSettings": {
|
||||
"title": "Vous êtes presque prêt à utiliser Lume.",
|
||||
"subtitle": "Commençons par personnaliser votre expérience.",
|
||||
"notification": {
|
||||
"title": "Notifications Push",
|
||||
"subtitle": "L'activation des notifications push vous permettra de recevoir des notifications de Lume."
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Mode Basse Consommation",
|
||||
"subtitle": "Connexion relais limitée et dissimulation de tous les médias, compatible avec les environnements avec peu de réseau."
|
||||
},
|
||||
"translation": {
|
||||
"title": "Traductions (nostr.wine)",
|
||||
"subtitle": "Traduisez votre texte dans la langue de votre choix, grâce à Nostr Wine."
|
||||
},
|
||||
"footer": "Vous pouvez configurer de nombreux autres paramètres à partir de l'écran 'Paramètres'. Ne manquez pas de les consulter ultérieurement."
|
||||
},
|
||||
"relays": {
|
||||
"global": "Général",
|
||||
"follows": "Suivis",
|
||||
"sidebar": {
|
||||
"title": "Relais connectés",
|
||||
"empty": "Vide."
|
||||
},
|
||||
"relayView": {
|
||||
"empty": "Impossible de charger les informations du relais 😬",
|
||||
"owner": "Propriétaire",
|
||||
"contact": "Contact",
|
||||
"software": "Logiciel",
|
||||
"nips": "NIPs supportés",
|
||||
"limit": "Limitation",
|
||||
"payment": "Ouvrir le site de paiement",
|
||||
"paymentNote": "Vous devez effectuer un paiement pour vous connecter à ce relais."
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"title": "Suivis Suggérés",
|
||||
"error": "Erreur. Impossible d'obtenir les utilisateurs en tendance",
|
||||
"button": "Enregistrer & Retour"
|
||||
},
|
||||
"interests": {
|
||||
"title": "Intérêts",
|
||||
"subtitle": "Choisissez les éléments que vous aimeriez voir apparaître dans votre flux d'accueil.",
|
||||
"edit": "Modifier vos Intérêts",
|
||||
"followAll": "Suivre tout le monde",
|
||||
"unfollowAll": "Ne plus suivre personne"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"title": "Général",
|
||||
"update": {
|
||||
"title": "Mises à jour",
|
||||
"subtitle": "Téléchargement automatique des nouvelles mises à jour"
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Faible Consommation",
|
||||
"subtitle": "Recommandé pour un environnement avec peu de réseau"
|
||||
},
|
||||
"startup": {
|
||||
"title": "Démarrage",
|
||||
"subtitle": "Lancer Lume au Démarrage"
|
||||
},
|
||||
"media": {
|
||||
"title": "Média",
|
||||
"subtitle": "Chargement automatique des médias"
|
||||
},
|
||||
"hashtag": {
|
||||
"title": "Hashtag",
|
||||
"subtitle": "Afficher tous les hashtags dans les contenus"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notification",
|
||||
"subtitle": "Envoi automatique des notifications"
|
||||
},
|
||||
"translation": {
|
||||
"title": "Traduction",
|
||||
"subtitle": "Traduire les textes dans votre langue"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "Utilisateur"
|
||||
},
|
||||
"zap": {
|
||||
"title": "Zap",
|
||||
"nwc": "Chaîne de connexion NWC"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Sauvegarde",
|
||||
"privkey": {
|
||||
"title": "Clé Privée",
|
||||
"button": "Supprimer la clé privée"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avancé",
|
||||
"cache": {
|
||||
"title": "Cache",
|
||||
"subtitle": "A utiliser pour accélérer la connexion nostr",
|
||||
"button": "Vider"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Zap instantané",
|
||||
"subtitle": "Zap avec montant par défaut, sans confirmation"
|
||||
},
|
||||
"defaultAmount": "Montant par défaut"
|
||||
},
|
||||
"about": {
|
||||
"title": "A propos",
|
||||
"version": "Version",
|
||||
"checkUpdate": "Rechercher des mises à jour",
|
||||
"installUpdate": "Installer"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"home": {
|
||||
"title": "Votre compte a été créé avec succès !",
|
||||
"subtitle": "Pour commencer, configurons votre profil.",
|
||||
"profileSettings": "Paramètres du Profil"
|
||||
},
|
||||
"profile": {
|
||||
"title": "A propos de vous",
|
||||
"subtitle": "Parlez de vous à Lume pour commencer à construire votre flux d'accueil."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Configuration du profil terminée !",
|
||||
"subtitle": "Vous pouvez quitter la configuration ici et commencer à utiliser Lume.",
|
||||
"report": "Signaler un problème"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activité",
|
||||
"empty": "Hey ! Rien de nouveau pour l'instant.",
|
||||
"mention": "vous a mentionné",
|
||||
"repost": "reposté",
|
||||
"zap": "zappé",
|
||||
"newReply": "Nouvelle réponse",
|
||||
"boost": "Booster",
|
||||
"boostSubtitle": "@ Quelqu'un a reposté votre note",
|
||||
"conversation": "Conversation",
|
||||
"conversationSubtitle": "@ Quelqu'un a répondu à votre note"
|
||||
}
|
||||
}
|
||||
295
src-tauri/locales/ja.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"global": {
|
||||
"relay": "リレー",
|
||||
"back": "戻る",
|
||||
"continue": "続行",
|
||||
"loading": "ロード中",
|
||||
"error": "エラー",
|
||||
"moveLeft": "左に移動",
|
||||
"moveRight": "右に移動",
|
||||
"newColumn": "新規カラム",
|
||||
"inspect": "のぞき見",
|
||||
"loadMore": "詳細",
|
||||
"delete": "削除",
|
||||
"refresh": "更新",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"post": "投稿",
|
||||
"update": "更新",
|
||||
"noResult": "結果が見つかりません",
|
||||
"emptyFeedTitle": "このフィードは空です",
|
||||
"emptyFeedSubtitle": "ユーザーをフォローしてタイムラインを構築しましょう",
|
||||
"apiKey": "API キー",
|
||||
"skip": "スキップ",
|
||||
"close": "閉じる"
|
||||
},
|
||||
"nip89": {
|
||||
"unsupported": "このイベントは非対応です",
|
||||
"openWith": "で開く"
|
||||
},
|
||||
"note": {
|
||||
"showThread": "スレッドの表示",
|
||||
"showMore": "さらに表示",
|
||||
"error": "イベントの取得に失敗しました",
|
||||
"posted": "が投稿しました",
|
||||
"replied": "が返信しました",
|
||||
"reposted": "がリポストしました",
|
||||
"menu": {
|
||||
"viewThread": "スレッドを見る",
|
||||
"copyLink": "シェアリンクをコピー",
|
||||
"copyNoteId": "投稿 ID をコピー",
|
||||
"copyAuthorId": "投稿者 ID をコピー",
|
||||
"viewAuthor": "投稿者を表示",
|
||||
"pinAuthor": "投稿者をピン留め",
|
||||
"copyRaw": "生イベントをコピー",
|
||||
"mute": "ミュート"
|
||||
},
|
||||
"buttons": {
|
||||
"pin": "ピン留め",
|
||||
"pinTooltip": "投稿をピン留めする",
|
||||
"repost": "リポスト",
|
||||
"quote": "引用",
|
||||
"viewProfile": "プロフィールの表示"
|
||||
},
|
||||
"zap": {
|
||||
"zap": "Zap",
|
||||
"tooltip": "zap する",
|
||||
"modalTitle": "に zap する",
|
||||
"messagePlaceholder": "メッセージを入力 (任意)",
|
||||
"buttonFinish": "Zap しました",
|
||||
"buttonLoading": "処理中...",
|
||||
"invoiceButton": "スキャンして zap",
|
||||
"invoiceFooter": "Blue Wallet、Bitkit、Phoenix などの\nBitcoin Lightningに対応したウォレットが必要です。"
|
||||
},
|
||||
"reply": {
|
||||
"single": "リプライ",
|
||||
"plural": "複数リプライ",
|
||||
"empty": "リプライしてみましょう!"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"avatar": "アバター",
|
||||
"displayName": "表示名",
|
||||
"name": "名前",
|
||||
"bio": "経歴",
|
||||
"lna": "ライトニングアドレス",
|
||||
"website": "Web サイト",
|
||||
"verified": "認証済",
|
||||
"unverified": "未認証",
|
||||
"follow": "フォロー",
|
||||
"unfollow": "アンフォロー",
|
||||
"latestPosts": "最新投稿",
|
||||
"avatarButton": "アバターの変更",
|
||||
"coverButton": "カバーの変更",
|
||||
"editProfile": "プロフィールの編集",
|
||||
"settings": "設定",
|
||||
"logout": "ログアウト",
|
||||
"logoutConfirmTitle": "本当にログアウトしてもよろしいですか?",
|
||||
"logoutConfirmSubtitle": "いつでも再ログインすることができます。アカウントを切り替えたいだけの場合は、既存のアカウントを追加することで切り替えられます。"
|
||||
},
|
||||
"editor": {
|
||||
"title": "新規投稿",
|
||||
"placeholder": "今何してる?",
|
||||
"successMessage": "投稿が正常に公開されました",
|
||||
"replyPlaceholder": "リプライを投稿する"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "入力して検索...",
|
||||
"empty": "人、投稿、キーワードなどを入力して検索しましょう"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Lume は、Nostr を通じて人々と出会い、探索し、\n自由に思いを共有するために作られた全く新しいクライアントです。",
|
||||
"signup": "Nostr を始める",
|
||||
"login": "ログイン",
|
||||
"footer": "Nostr を始める前に、Nostrについて学ぶ"
|
||||
},
|
||||
"login": {
|
||||
"title": "おかえりなさい、名無しさん!",
|
||||
"footer": "Lume は、秘密鍵をそれぞれの OS に対応したセキュア・ストレージ内に保管します。また、入力された秘密鍵は、パスワードや生体認証 ID によって保護されます。",
|
||||
"loginWithAddress": "Nostr アドレスでログイン",
|
||||
"loginWithBunker": "nsecBunker でログイン",
|
||||
"or": "または、",
|
||||
"loginWithPrivkey": "秘密鍵でログイン"
|
||||
},
|
||||
"loginWithAddress": {
|
||||
"title": "Nostr アドレスを入力"
|
||||
},
|
||||
"loginWithBunker": {
|
||||
"title": "nsecBunker のトークンを入力"
|
||||
},
|
||||
"loginWithPrivkey": {
|
||||
"title": "秘密鍵を入力",
|
||||
"subtitle": "秘密鍵は <1>{{service}}</1> 内に保管されます。\nまた、OSによって秘密鍵は保護されます。"
|
||||
},
|
||||
"signup": {
|
||||
"title": "さぁ、はじめましょう",
|
||||
"subtitle": "以下からアカウント作成の方法を 1 つ選択してください",
|
||||
"selfManageMethod": "自己管理",
|
||||
"selfManageMethodDescription": "自分で秘密鍵を作成し安全に保管する",
|
||||
"providerMethod": "プロバイダー管理",
|
||||
"providerMethodDescription": "サードパーティプロバイダーに鍵管理を委任する"
|
||||
},
|
||||
"signupWithSelfManage": {
|
||||
"title": "これが新しいアカウント用の鍵です",
|
||||
"subtitle": "秘密鍵は安全な場所に保管してください。紛失してしまった場合は、永遠にアカウントにアクセスできなくなります。",
|
||||
"confirm1": "秘密鍵を紛失した場合のリスクを理解しました。",
|
||||
"confirm2": "秘密鍵を安全な場所に保管し、誰にも共有しないことを承諾しました。",
|
||||
"confirm3": "秘密鍵の復元ができないことを理解しました。",
|
||||
"button": "鍵を保存して続行"
|
||||
},
|
||||
"signupWithProvider": {
|
||||
"title": "さぁ、Nostr のアカウントを設定しましょう",
|
||||
"username": "ユーザー名 *",
|
||||
"chooseProvider": "プロバイダーを選択",
|
||||
"usernameFooter": "Lume や、他の Nostr アプリにログインする場合に使用されます。アカウントを管理するプロバイダーは慎重に選択してください",
|
||||
"email": "E メールでバックアップ (任意)",
|
||||
"emailFooter": "パスワードを紛失した場合に使用されます"
|
||||
},
|
||||
"onboardingSettings": {
|
||||
"title": "あともう少しです",
|
||||
"subtitle": "Lume の体験を最適化しましょう",
|
||||
"notification": {
|
||||
"title": "プッシュ通知",
|
||||
"subtitle": "プッシュ通知を有効化すると Lume から通知を受け取ることができるようになります。"
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "低電力モード",
|
||||
"subtitle": "リレーの接続を制限し、すべてのメディアを非表示にします。ネットワーク環境が悪い場合などにも有効です。"
|
||||
},
|
||||
"translation": {
|
||||
"title": "翻訳 (nostr.wine)",
|
||||
"subtitle": "Nostr Wine を使用し、希望の言語に自動翻訳することができます。"
|
||||
},
|
||||
"footer": "「設定」画面からさらに細かくカスタマイズすることができます。 後で確認するのを忘れずに。"
|
||||
},
|
||||
"relays": {
|
||||
"global": "グローバル",
|
||||
"follows": "フォロー",
|
||||
"sidebar": {
|
||||
"title": "接続済リレー",
|
||||
"empty": "空"
|
||||
},
|
||||
"relayView": {
|
||||
"empty": "リレー情報がロードできませんでした 😬",
|
||||
"owner": "オーナー",
|
||||
"contact": "連絡先",
|
||||
"software": "ソフトウェア",
|
||||
"nips": "対応 NIPs",
|
||||
"limit": "制限",
|
||||
"payment": "決済用 Web サイトを開く",
|
||||
"paymentNote": "このリレーへの接続には支払いが必要です"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"title": "フォロー推奨 Nostrich",
|
||||
"error": "エラーが発生しています。人気の Nostrich 情報が取得ができませんでした",
|
||||
"button": "保存して戻る"
|
||||
},
|
||||
"interests": {
|
||||
"title": "興味",
|
||||
"subtitle": "ホーム・フィードに表示したい内容を選択",
|
||||
"edit": "興味を編集",
|
||||
"followAll": "全てフォローする",
|
||||
"unfollowAll": "全てアンフォローする"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"title": "一般",
|
||||
"update": {
|
||||
"title": "アップデート",
|
||||
"subtitle": "自動で新規アップデートをダウンロードする"
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "低電力モード",
|
||||
"subtitle": "ネットワーク環境が悪い場合などにオススメ"
|
||||
},
|
||||
"startup": {
|
||||
"title": "起動時",
|
||||
"subtitle": "ログイン時に Lume を起動する"
|
||||
},
|
||||
"media": {
|
||||
"title": "メディア",
|
||||
"subtitle": "自動的にメディアをロードする"
|
||||
},
|
||||
"hashtag": {
|
||||
"title": "ハッシュタグ",
|
||||
"subtitle": "コンテンツ内のハッシュタグを全て表示する"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知",
|
||||
"subtitle": "プッシュ通知の自動化"
|
||||
},
|
||||
"translation": {
|
||||
"title": "翻訳",
|
||||
"subtitle": "好みの言語に自動翻訳"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外観",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク",
|
||||
"system": "システム"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "ユーザー"
|
||||
},
|
||||
"zap": {
|
||||
"title": "Zap",
|
||||
"nwc": "接続文字列"
|
||||
},
|
||||
"backup": {
|
||||
"title": "バックアップ",
|
||||
"privkey": {
|
||||
"title": "秘密鍵",
|
||||
"button": "秘密鍵の削除"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高度な設定",
|
||||
"cache": {
|
||||
"title": "キャッシュ",
|
||||
"subtitle": "接続を高速化する際に使用",
|
||||
"button": "クリア"
|
||||
},
|
||||
"instant": {
|
||||
"title": "即 Zap",
|
||||
"subtitle": "確認なしで設定されたデフォルト枚数を zap する"
|
||||
},
|
||||
"defaultAmount": "デフォルト枚数"
|
||||
},
|
||||
"about": {
|
||||
"title": "概要",
|
||||
"version": "バージョン",
|
||||
"checkUpdate": "アップデートの確認",
|
||||
"installUpdate": "インストール"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"home": {
|
||||
"title": "アカウントが正常に作成されました!",
|
||||
"subtitle": "初めての方はプロフィールの設定をしましょう。",
|
||||
"profileSettings": "プロフィール設定"
|
||||
},
|
||||
"profile": {
|
||||
"title": "あなたについて",
|
||||
"subtitle": "ホーム・フィードを作成するためにあなたについてもう少し教えてください。"
|
||||
},
|
||||
"finish": {
|
||||
"title": "プロフィールの設定が完了しました!",
|
||||
"subtitle": "お疲れ様でした!設定はここで終了です。 Lume を開始できます",
|
||||
"report": "問題を報告する"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "アクティビティ",
|
||||
"empty": "オッ! まだ何もないようですネ!",
|
||||
"mention": "にメンションされました",
|
||||
"repost": "にリポストされました",
|
||||
"zap": "に Zapされました",
|
||||
"newReply": "新規リプライ",
|
||||
"boost": "ブースト",
|
||||
"boostSubtitle": "@ 誰かがあなたの投稿をリポストしました",
|
||||
"conversation": "会話",
|
||||
"conversationSubtitle": "@ 誰かがあなたの投稿にリプライしました"
|
||||
}
|
||||
}
|
||||
295
src-tauri/locales/ru.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"global": {
|
||||
"relay": "Relay",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"loading": "Loading",
|
||||
"error": "Error",
|
||||
"moveLeft": "Move Left",
|
||||
"moveRight": "Move Right",
|
||||
"newColumn": "New Column",
|
||||
"inspect": "Inspect",
|
||||
"loadMore": "Load more",
|
||||
"delete": "Delete",
|
||||
"refresh": "Refresh",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"post": "Post",
|
||||
"update": "Update",
|
||||
"noResult": "No results found.",
|
||||
"emptyFeedTitle": "This feed is empty",
|
||||
"emptyFeedSubtitle": "You can follow more users to build up your timeline",
|
||||
"apiKey": "API Key",
|
||||
"skip": "Skip",
|
||||
"close": "Close"
|
||||
},
|
||||
"nip89": {
|
||||
"unsupported": "Lume isn't support this event",
|
||||
"openWith": "Open with"
|
||||
},
|
||||
"note": {
|
||||
"showThread": "Show thread",
|
||||
"showMore": "Show more",
|
||||
"error": "Failed to fetch event.",
|
||||
"posted": "posted",
|
||||
"replied": "replied",
|
||||
"reposted": "reposted",
|
||||
"menu": {
|
||||
"viewThread": "View thread",
|
||||
"copyLink": "Copy shareable link",
|
||||
"copyNoteId": "Copy note ID",
|
||||
"copyAuthorId": "Copy author ID",
|
||||
"viewAuthor": "View author",
|
||||
"pinAuthor": "Pin author",
|
||||
"copyRaw": "Copy raw event",
|
||||
"mute": "Mute"
|
||||
},
|
||||
"buttons": {
|
||||
"pin": "Pin",
|
||||
"pinTooltip": "Pin Note",
|
||||
"repost": "Repost",
|
||||
"quote": "Quote",
|
||||
"viewProfile": "View profile"
|
||||
},
|
||||
"zap": {
|
||||
"zap": "Zap",
|
||||
"tooltip": "Send zap",
|
||||
"modalTitle": "Send zap to",
|
||||
"messagePlaceholder": "Enter message (optional)",
|
||||
"buttonFinish": "Zapped",
|
||||
"buttonLoading": "Processing...",
|
||||
"invoiceButton": "Scan to zap",
|
||||
"invoiceFooter": "You must use Bitcoin wallet which support Lightning\nsuch as: Blue Wallet, Bitkit, Phoenix,..."
|
||||
},
|
||||
"reply": {
|
||||
"single": "reply",
|
||||
"plural": "replies",
|
||||
"empty": "Be the first to Reply!"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"avatar": "Avatar",
|
||||
"displayName": "Display Name",
|
||||
"name": "Name",
|
||||
"bio": "Bio",
|
||||
"lna": "Lightning address",
|
||||
"website": "Website",
|
||||
"verified": "Verified",
|
||||
"unverified": "Unverified",
|
||||
"follow": "Follow",
|
||||
"unfollow": "Unfollow",
|
||||
"latestPosts": "Latest posts",
|
||||
"avatarButton": "Change avatar",
|
||||
"coverButton": "Change cover",
|
||||
"editProfile": "Edit profile",
|
||||
"settings": "Settings",
|
||||
"logout": "Log out",
|
||||
"logoutConfirmTitle": "Are you sure!",
|
||||
"logoutConfirmSubtitle": "You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account."
|
||||
},
|
||||
"editor": {
|
||||
"title": "New Post",
|
||||
"placeholder": "What are you up to?",
|
||||
"successMessage": "Your note has been published successfully.",
|
||||
"replyPlaceholder": "Post your reply"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Type something to search...",
|
||||
"empty": "Try searching for people, notes, or keywords"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Lume is a magnificent client for Nostr to meet, explore\nand freely share your thoughts with everyone.",
|
||||
"signup": "Join Nostr",
|
||||
"login": "Login",
|
||||
"footer": "Before joining Nostr, you can take time to learn more about Nostr"
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome back, anon!",
|
||||
"footer": "Lume will put your Private Key in Secure Storage depended on your OS Platform. It will be secured by Password or Biometric ID",
|
||||
"loginWithAddress": "Login with Nostr Address",
|
||||
"loginWithBunker": "Login with nsecBunker",
|
||||
"or": "Or continue with",
|
||||
"loginWithPrivkey": "Login with Private Key"
|
||||
},
|
||||
"loginWithAddress": {
|
||||
"title": "Enter your Nostr Address"
|
||||
},
|
||||
"loginWithBunker": {
|
||||
"title": "Enter your nsecbunker token"
|
||||
},
|
||||
"loginWithPrivkey": {
|
||||
"title": "Enter your Private Key",
|
||||
"subtitle": "Lume will put your private key to <1>{{service}}</1>.\nIt will be secured by your OS."
|
||||
},
|
||||
"signup": {
|
||||
"title": "Let's Get Started",
|
||||
"subtitle": "Choose one of methods below to create your account",
|
||||
"selfManageMethod": "Self-Managed",
|
||||
"selfManageMethodDescription": "You create your keys and keep them safe.",
|
||||
"providerMethod": "Managed by Provider",
|
||||
"providerMethodDescription": "A 3rd party provider will handle your sign in keys for you."
|
||||
},
|
||||
"signupWithSelfManage": {
|
||||
"title": "This is your new Account Key",
|
||||
"subtitle": "Keep your key in safe place. If you lose this key, you will lose access to your account.",
|
||||
"confirm1": "I understand the risk of lost private key.",
|
||||
"confirm2": "I will make sure keep it safe and not sharing with anyone.",
|
||||
"confirm3": "I understand I cannot recover private key.",
|
||||
"button": "Save key & Continue"
|
||||
},
|
||||
"signupWithProvider": {
|
||||
"title": "Let's set up your account on Nostr",
|
||||
"username": "Username *",
|
||||
"chooseProvider": "Choose a Provider",
|
||||
"usernameFooter": "Use to login to Lume and other Nostr apps. You can choose provider you trust to manage your account",
|
||||
"email": "Backup Email (optional)",
|
||||
"emailFooter": "Use for recover your account if you lose your password"
|
||||
},
|
||||
"onboardingSettings": {
|
||||
"title": "You're almost ready to use Lume.",
|
||||
"subtitle": "Let's start personalizing your experience.",
|
||||
"notification": {
|
||||
"title": "Push notification",
|
||||
"subtitle": "Enabling push notifications will allow you to receive notifications from Lume."
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Low Power Mode",
|
||||
"subtitle": "Limited relay connection and hide all media, sustainable for low network environment."
|
||||
},
|
||||
"translation": {
|
||||
"title": "Translation (nostr.wine)",
|
||||
"subtitle": "Translate text to your preferred language, powered by Nostr Wine."
|
||||
},
|
||||
"footer": "There are many more settings you can configure from the 'Settings' Screen. Be sure to visit it later."
|
||||
},
|
||||
"relays": {
|
||||
"global": "Global",
|
||||
"follows": "Follows",
|
||||
"sidebar": {
|
||||
"title": "Connected relays",
|
||||
"empty": "Empty."
|
||||
},
|
||||
"relayView": {
|
||||
"empty": "Could not load relay information 😬",
|
||||
"owner": "Owner",
|
||||
"contact": "Contact",
|
||||
"software": "Software",
|
||||
"nips": "Supported NIPs",
|
||||
"limit": "Limitation",
|
||||
"payment": "Open payment website",
|
||||
"paymentNote": "You need to make a payment to connect this relay"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"title": "Suggested Follows",
|
||||
"error": "Error. Cannot get trending users",
|
||||
"button": "Save & Go back"
|
||||
},
|
||||
"interests": {
|
||||
"title": "Interests",
|
||||
"subtitle": "Pick things you'd like to see in your home feed.",
|
||||
"edit": "Edit Interest",
|
||||
"followAll": "Follow All",
|
||||
"unfollowAll": "Unfollow All"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"update": {
|
||||
"title": "Update",
|
||||
"subtitle": "Automatically download new update"
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Low Power",
|
||||
"subtitle": "Sustainable for low network environment"
|
||||
},
|
||||
"startup": {
|
||||
"title": "Startup",
|
||||
"subtitle": "Launch Lume at Login"
|
||||
},
|
||||
"media": {
|
||||
"title": "Media",
|
||||
"subtitle": "Automatically load media"
|
||||
},
|
||||
"hashtag": {
|
||||
"title": "Hashtag",
|
||||
"subtitle": "Show all hashtags in content"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notification",
|
||||
"subtitle": "Automatically send notification"
|
||||
},
|
||||
"translation": {
|
||||
"title": "Translation",
|
||||
"subtitle": "Translate text to your language"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "User"
|
||||
},
|
||||
"zap": {
|
||||
"title": "Zap",
|
||||
"nwc": "Connection String"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
"privkey": {
|
||||
"title": "Private key",
|
||||
"button": "Remove private key"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"cache": {
|
||||
"title": "Cache",
|
||||
"subtitle": "Use for boost up nostr connection",
|
||||
"button": "Clear"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Instant Zap",
|
||||
"subtitle": "Zap with default amount, no confirmation"
|
||||
},
|
||||
"defaultAmount": "Default amount"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"version": "Version",
|
||||
"checkUpdate": "Check for update",
|
||||
"installUpdate": "Install"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"home": {
|
||||
"title": "Your account was successfully created!",
|
||||
"subtitle": "For starters, let's set up your profile.",
|
||||
"profileSettings": "Profile Settings"
|
||||
},
|
||||
"profile": {
|
||||
"title": "About you",
|
||||
"subtitle": "Tell Lume about yourself to start building your home feed."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Profile setup complete!",
|
||||
"subtitle": "You can exit the setup here and start using Lume.",
|
||||
"report": "Report a issue"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity",
|
||||
"empty": "Yo! Nothing new yet.",
|
||||
"mention": "mention you",
|
||||
"repost": "reposted",
|
||||
"zap": "zapped",
|
||||
"newReply": "New reply",
|
||||
"boost": "Boost",
|
||||
"boostSubtitle": "@ Someone has reposted to your note",
|
||||
"conversation": "Conversation",
|
||||
"conversationSubtitle": "@ Someone has replied to your note"
|
||||
}
|
||||
}
|
||||
295
src-tauri/locales/vi.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"global": {
|
||||
"relay": "Relay",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"loading": "Loading",
|
||||
"error": "Error",
|
||||
"moveLeft": "Move Left",
|
||||
"moveRight": "Move Right",
|
||||
"newColumn": "New Column",
|
||||
"inspect": "Inspect",
|
||||
"loadMore": "Load more",
|
||||
"delete": "Delete",
|
||||
"refresh": "Refresh",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"post": "Post",
|
||||
"update": "Update",
|
||||
"noResult": "No results found.",
|
||||
"emptyFeedTitle": "This feed is empty",
|
||||
"emptyFeedSubtitle": "You can follow more users to build up your timeline",
|
||||
"apiKey": "API Key",
|
||||
"skip": "Skip",
|
||||
"close": "Close"
|
||||
},
|
||||
"nip89": {
|
||||
"unsupported": "Lume isn't support this event",
|
||||
"openWith": "Open with"
|
||||
},
|
||||
"note": {
|
||||
"showThread": "Show thread",
|
||||
"showMore": "Show more",
|
||||
"error": "Failed to fetch event.",
|
||||
"posted": "posted",
|
||||
"replied": "replied",
|
||||
"reposted": "reposted",
|
||||
"menu": {
|
||||
"viewThread": "View thread",
|
||||
"copyLink": "Copy shareable link",
|
||||
"copyNoteId": "Copy note ID",
|
||||
"copyAuthorId": "Copy author ID",
|
||||
"viewAuthor": "View author",
|
||||
"pinAuthor": "Pin author",
|
||||
"copyRaw": "Copy raw event",
|
||||
"mute": "Mute"
|
||||
},
|
||||
"buttons": {
|
||||
"pin": "Pin",
|
||||
"pinTooltip": "Pin Note",
|
||||
"repost": "Repost",
|
||||
"quote": "Quote",
|
||||
"viewProfile": "View profile"
|
||||
},
|
||||
"zap": {
|
||||
"zap": "Zap",
|
||||
"tooltip": "Send zap",
|
||||
"modalTitle": "Send zap to",
|
||||
"messagePlaceholder": "Enter message (optional)",
|
||||
"buttonFinish": "Zapped",
|
||||
"buttonLoading": "Processing...",
|
||||
"invoiceButton": "Scan to zap",
|
||||
"invoiceFooter": "You must use Bitcoin wallet which support Lightning\nsuch as: Blue Wallet, Bitkit, Phoenix,..."
|
||||
},
|
||||
"reply": {
|
||||
"single": "reply",
|
||||
"plural": "replies",
|
||||
"empty": "Be the first to Reply!"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"avatar": "Avatar",
|
||||
"displayName": "Display Name",
|
||||
"name": "Name",
|
||||
"bio": "Bio",
|
||||
"lna": "Lightning address",
|
||||
"website": "Website",
|
||||
"verified": "Verified",
|
||||
"unverified": "Unverified",
|
||||
"follow": "Follow",
|
||||
"unfollow": "Unfollow",
|
||||
"latestPosts": "Latest posts",
|
||||
"avatarButton": "Change avatar",
|
||||
"coverButton": "Change cover",
|
||||
"editProfile": "Edit profile",
|
||||
"settings": "Settings",
|
||||
"logout": "Log out",
|
||||
"logoutConfirmTitle": "Are you sure!",
|
||||
"logoutConfirmSubtitle": "You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account."
|
||||
},
|
||||
"editor": {
|
||||
"title": "New Post",
|
||||
"placeholder": "What are you up to?",
|
||||
"successMessage": "Your note has been published successfully.",
|
||||
"replyPlaceholder": "Post your reply"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Type something to search...",
|
||||
"empty": "Try searching for people, notes, or keywords"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Lume is a magnificent client for Nostr to meet, explore\nand freely share your thoughts with everyone.",
|
||||
"signup": "Join Nostr",
|
||||
"login": "Login",
|
||||
"footer": "Before joining Nostr, you can take time to learn more about Nostr"
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome back, anon!",
|
||||
"footer": "Lume will put your Private Key in Secure Storage depended on your OS Platform. It will be secured by Password or Biometric ID",
|
||||
"loginWithAddress": "Login with Nostr Address",
|
||||
"loginWithBunker": "Login with nsecBunker",
|
||||
"or": "Or continue with",
|
||||
"loginWithPrivkey": "Login with Private Key"
|
||||
},
|
||||
"loginWithAddress": {
|
||||
"title": "Enter your Nostr Address"
|
||||
},
|
||||
"loginWithBunker": {
|
||||
"title": "Enter your nsecbunker token"
|
||||
},
|
||||
"loginWithPrivkey": {
|
||||
"title": "Enter your Private Key",
|
||||
"subtitle": "Lume will put your private key to <1>{{service}}</1>.\nIt will be secured by your OS."
|
||||
},
|
||||
"signup": {
|
||||
"title": "Let's Get Started",
|
||||
"subtitle": "Choose one of methods below to create your account",
|
||||
"selfManageMethod": "Self-Managed",
|
||||
"selfManageMethodDescription": "You create your keys and keep them safe.",
|
||||
"providerMethod": "Managed by Provider",
|
||||
"providerMethodDescription": "A 3rd party provider will handle your sign in keys for you."
|
||||
},
|
||||
"signupWithSelfManage": {
|
||||
"title": "This is your new Account Key",
|
||||
"subtitle": "Keep your key in safe place. If you lose this key, you will lose access to your account.",
|
||||
"confirm1": "I understand the risk of lost private key.",
|
||||
"confirm2": "I will make sure keep it safe and not sharing with anyone.",
|
||||
"confirm3": "I understand I cannot recover private key.",
|
||||
"button": "Save key & Continue"
|
||||
},
|
||||
"signupWithProvider": {
|
||||
"title": "Let's set up your account on Nostr",
|
||||
"username": "Username *",
|
||||
"chooseProvider": "Choose a Provider",
|
||||
"usernameFooter": "Use to login to Lume and other Nostr apps. You can choose provider you trust to manage your account",
|
||||
"email": "Backup Email (optional)",
|
||||
"emailFooter": "Use for recover your account if you lose your password"
|
||||
},
|
||||
"onboardingSettings": {
|
||||
"title": "You're almost ready to use Lume.",
|
||||
"subtitle": "Let's start personalizing your experience.",
|
||||
"notification": {
|
||||
"title": "Push notification",
|
||||
"subtitle": "Enabling push notifications will allow you to receive notifications from Lume."
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Low Power Mode",
|
||||
"subtitle": "Limited relay connection and hide all media, sustainable for low network environment."
|
||||
},
|
||||
"translation": {
|
||||
"title": "Translation (nostr.wine)",
|
||||
"subtitle": "Translate text to your preferred language, powered by Nostr Wine."
|
||||
},
|
||||
"footer": "There are many more settings you can configure from the 'Settings' Screen. Be sure to visit it later."
|
||||
},
|
||||
"relays": {
|
||||
"global": "Global",
|
||||
"follows": "Follows",
|
||||
"sidebar": {
|
||||
"title": "Connected relays",
|
||||
"empty": "Empty."
|
||||
},
|
||||
"relayView": {
|
||||
"empty": "Could not load relay information 😬",
|
||||
"owner": "Owner",
|
||||
"contact": "Contact",
|
||||
"software": "Software",
|
||||
"nips": "Supported NIPs",
|
||||
"limit": "Limitation",
|
||||
"payment": "Open payment website",
|
||||
"paymentNote": "You need to make a payment to connect this relay"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"title": "Suggested Follows",
|
||||
"error": "Error. Cannot get trending users",
|
||||
"button": "Save & Go back"
|
||||
},
|
||||
"interests": {
|
||||
"title": "Interests",
|
||||
"subtitle": "Pick things you'd like to see in your home feed.",
|
||||
"edit": "Edit Interest",
|
||||
"followAll": "Follow All",
|
||||
"unfollowAll": "Unfollow All"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"update": {
|
||||
"title": "Update",
|
||||
"subtitle": "Automatically download new update"
|
||||
},
|
||||
"lowPower": {
|
||||
"title": "Low Power",
|
||||
"subtitle": "Sustainable for low network environment"
|
||||
},
|
||||
"startup": {
|
||||
"title": "Startup",
|
||||
"subtitle": "Launch Lume at Login"
|
||||
},
|
||||
"media": {
|
||||
"title": "Media",
|
||||
"subtitle": "Automatically load media"
|
||||
},
|
||||
"hashtag": {
|
||||
"title": "Hashtag",
|
||||
"subtitle": "Show all hashtags in content"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notification",
|
||||
"subtitle": "Automatically send notification"
|
||||
},
|
||||
"translation": {
|
||||
"title": "Translation",
|
||||
"subtitle": "Translate text to your language"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "User"
|
||||
},
|
||||
"zap": {
|
||||
"title": "Zap",
|
||||
"nwc": "Connection String"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
"privkey": {
|
||||
"title": "Private key",
|
||||
"button": "Remove private key"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"cache": {
|
||||
"title": "Cache",
|
||||
"subtitle": "Use for boost up nostr connection",
|
||||
"button": "Clear"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Instant Zap",
|
||||
"subtitle": "Zap with default amount, no confirmation"
|
||||
},
|
||||
"defaultAmount": "Default amount"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"version": "Version",
|
||||
"checkUpdate": "Check for update",
|
||||
"installUpdate": "Install"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"home": {
|
||||
"title": "Your account was successfully created!",
|
||||
"subtitle": "For starters, let's set up your profile.",
|
||||
"profileSettings": "Profile Settings"
|
||||
},
|
||||
"profile": {
|
||||
"title": "About you",
|
||||
"subtitle": "Tell Lume about yourself to start building your home feed."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Profile setup complete!",
|
||||
"subtitle": "You can exit the setup here and start using Lume.",
|
||||
"report": "Report a issue"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity",
|
||||
"empty": "Yo! Nothing new yet.",
|
||||
"mention": "mention you",
|
||||
"repost": "reposted",
|
||||
"zap": "zapped",
|
||||
"newReply": "New reply",
|
||||
"boost": "Boost",
|
||||
"boostSubtitle": "@ Someone has reposted to your note",
|
||||
"conversation": "Conversation",
|
||||
"conversationSubtitle": "@ Someone has replied to your note"
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
-- Add migration script here
|
||||
-- create accounts table
|
||||
-- is_active (multi-account feature), value:
|
||||
-- 0: false
|
||||
-- 1: true
|
||||
CREATE TABLE
|
||||
accounts (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
npub TEXT NOT NULL UNIQUE,
|
||||
pubkey TEXT NOT NULL UNIQUE,
|
||||
privkey TEXT NOT NULL,
|
||||
follows JSON,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- create notes table
|
||||
CREATE TABLE
|
||||
notes (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
account_id INTEGER NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
kind INTEGER NOT NULL DEFAULT 1,
|
||||
tags JSON,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
parent_id TEXT,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts (id)
|
||||
);
|
||||
|
||||
-- create channels table
|
||||
CREATE TABLE
|
||||
channels (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
about TEXT,
|
||||
picture TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- create settings table
|
||||
CREATE TABLE
|
||||
settings (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- create metadata table
|
||||
CREATE TABLE
|
||||
metadata (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
pubkey TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Add migration script here
|
||||
-- create chats table
|
||||
CREATE TABLE
|
||||
chats (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
receiver_pubkey INTEGER NOT NULL,
|
||||
sender_pubkey TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tags JSON,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Add migration script here
|
||||
INSERT INTO
|
||||
settings (key, value)
|
||||
VALUES
|
||||
("last_login", "0"),
|
||||
(
|
||||
"relays",
|
||||
'["wss://relayable.org","wss://relay.damus.io","wss://relay.nostr.band/all","wss://relay.nostrgraph.net","wss://nostr.mutinywallet.com"]'
|
||||
),
|
||||
("auto_start", "0"),
|
||||
("cache_time", "86400000"),
|
||||
("compose_shortcut", "meta+n"),
|
||||
("add_imageblock_shortcut", "meta+i"),
|
||||
("add_feedblock_shortcut", "meta+f")
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Add migration script here
|
||||
-- add pubkey to channel
|
||||
ALTER TABLE channels ADD pubkey TEXT NOT NULL DEFAULT '';
|
||||
@@ -1,38 +0,0 @@
|
||||
-- Add migration script here
|
||||
INSERT
|
||||
OR IGNORE INTO channels (
|
||||
event_id,
|
||||
pubkey,
|
||||
name,
|
||||
about,
|
||||
picture,
|
||||
created_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
"e3cadf5beca1b2af1cddaa41a633679bedf263e3de1eb229c6686c50d85df753",
|
||||
"126103bfddc8df256b6e0abfd7f3797c80dcc4ea88f7c2f87dd4104220b4d65f",
|
||||
"lume-general",
|
||||
"General channel for Lume",
|
||||
"https://void.cat/d/UNyxBmAh1MUx5gQTX95jyf.webp",
|
||||
1681898574
|
||||
);
|
||||
|
||||
INSERT
|
||||
OR IGNORE INTO channels (
|
||||
event_id,
|
||||
pubkey,
|
||||
name,
|
||||
about,
|
||||
picture,
|
||||
created_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
|
||||
"ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69",
|
||||
"Nostr",
|
||||
"",
|
||||
"https://cloudflare-ipfs.com/ipfs/QmTN4Eas9atUULVbEAbUU8cowhtvK7g3t7jfKztY7wc8eP?.png",
|
||||
1661333723
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Add migration script here
|
||||
-- create blacklist table
|
||||
CREATE TABLE
|
||||
blacklist (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL UNIQUE,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
kind INTEGER NOT NULL,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts (id)
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Add migration script here
|
||||
CREATE TABLE
|
||||
blocks (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts (id)
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Add migration script here
|
||||
CREATE TABLE
|
||||
channel_messages (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
pubkey TEXT NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tags JSON,
|
||||
mute BOOLEAN DEFAULT 0,
|
||||
hide BOOLEAN DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (channel_id) REFERENCES channels (event_id)
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Add migration script here
|
||||
CREATE TABLE
|
||||
replies (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
parent_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
pubkey TEXT NOT NULL,
|
||||
kind INTEGER NOT NULL DEFAULT 1,
|
||||
tags JSON,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES notes (event_id)
|
||||
);
|
||||
0
src-tauri/resources/.keep
Normal file
37
src-tauri/resources/columns.json
Normal file
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"default": true,
|
||||
"label": "onboarding",
|
||||
"name": "Onboarding",
|
||||
"description": "Tips for Mastering Lume.",
|
||||
"url": "/columns/onboarding"
|
||||
},
|
||||
{
|
||||
"default": true,
|
||||
"label": "launchpad",
|
||||
"name": "Launchpad",
|
||||
"description": "Expand your experiences.",
|
||||
"url": "/columns/launchpad"
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "search",
|
||||
"name": "Search",
|
||||
"description": "Find anything.",
|
||||
"url": "/columns/search"
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "global_feeds",
|
||||
"name": "Global Feeds",
|
||||
"description": "All global notes from all connected relays.",
|
||||
"url": "/columns/global"
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "trending",
|
||||
"name": "Trending",
|
||||
"description": "Discover all trending notes.",
|
||||
"url": "/columns/trending"
|
||||
}
|
||||
]
|
||||
5
src-tauri/resources/relays.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
wss://relay.damus.io,
|
||||
wss://relay.primal.net,
|
||||
wss://nostr.fmt.wiz.biz,
|
||||
wss://directory.yabu.me,
|
||||
wss://purplepag.es,
|
||||
273
src-tauri/src/commands/account.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use keyring::Entry;
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::{common::get_all_accounts, Nostr, Settings};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
struct Account {
|
||||
secret_key: String,
|
||||
nostr_connect: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AuthHandler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AuthUrlHandler for AuthHandler {
|
||||
async fn on_auth_url(&self, auth_url: Url) -> Result<()> {
|
||||
webbrowser::open(auth_url.as_str())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_accounts() -> Vec<String> {
|
||||
get_all_accounts()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn watch_account(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||
let npub = public_key.to_bech32().map_err(|e| e.to_string())?;
|
||||
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
|
||||
|
||||
// Set empty password
|
||||
keyring.set_password("").map_err(|e| e.to_string())?;
|
||||
|
||||
// Get user's profile
|
||||
let _ = client
|
||||
.fetch_metadata(public_key, Some(Duration::from_secs(4)))
|
||||
.await;
|
||||
|
||||
Ok(npub)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn import_account(
|
||||
key: String,
|
||||
password: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
|
||||
// Create secret key
|
||||
let secret_key = if let Some(pw) = password {
|
||||
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
|
||||
enc.to_secret_key(pw).map_err(|err| err.to_string())?
|
||||
} else {
|
||||
SecretKey::from_str(&key).map_err(|err| err.to_string())?
|
||||
};
|
||||
|
||||
let hex = secret_key.to_secret_hex();
|
||||
let keys = Keys::new(secret_key);
|
||||
|
||||
let public_key = keys.public_key();
|
||||
let npub = public_key.to_bech32().map_err(|err| err.to_string())?;
|
||||
|
||||
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
|
||||
|
||||
let account = Account {
|
||||
secret_key: hex,
|
||||
nostr_connect: None,
|
||||
};
|
||||
|
||||
// Save secret key to keyring
|
||||
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
|
||||
|
||||
// Update signer
|
||||
client.set_signer(keys).await;
|
||||
|
||||
Ok(npub)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let bunker_uri = NostrConnectURI::parse(&uri).map_err(|err| err.to_string())?;
|
||||
|
||||
// Local user
|
||||
let app_keys = Keys::generate();
|
||||
let app_secret = app_keys.secret_key().to_secret_hex();
|
||||
|
||||
// Get remote user
|
||||
let remote_user = bunker_uri.remote_signer_public_key().unwrap();
|
||||
let remote_npub = remote_user.to_bech32().map_err(|err| err.to_string())?;
|
||||
|
||||
// Init nostr connect
|
||||
let mut nostr_connect = NostrConnect::new(bunker_uri, app_keys, Duration::from_secs(120), None)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// Handle auth url
|
||||
nostr_connect.auth_url_handler(AuthHandler);
|
||||
|
||||
let keyring = Entry::new("Lume Safe Storage", &remote_npub).map_err(|err| err.to_string())?;
|
||||
|
||||
let reuse_bunker = nostr_connect
|
||||
.bunker_uri()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let mut reuse_uri = reuse_bunker.to_string();
|
||||
|
||||
if let Some(secret) = reuse_bunker.secret() {
|
||||
let replace = format!("&secret={}", secret);
|
||||
reuse_uri = reuse_uri.replace(replace.as_str(), "");
|
||||
}
|
||||
|
||||
let account = Account {
|
||||
secret_key: app_secret,
|
||||
nostr_connect: Some(reuse_uri),
|
||||
};
|
||||
|
||||
// Save secret key to keyring
|
||||
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
|
||||
|
||||
// Update signer
|
||||
let _ = client.set_signer(nostr_connect).await;
|
||||
|
||||
Ok(remote_npub)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn reset_password(key: String, password: String) -> Result<(), String> {
|
||||
let secret_key = SecretKey::from_bech32(key).map_err(|err| err.to_string())?;
|
||||
let keys = Keys::new(secret_key.clone());
|
||||
let npub = keys.public_key().to_bech32().unwrap();
|
||||
|
||||
let enc = EncryptedSecretKey::new(&secret_key, password, 16, KeySecurity::Medium)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
||||
|
||||
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
|
||||
let account = Account {
|
||||
secret_key: enc_bech32,
|
||||
nostr_connect: None,
|
||||
};
|
||||
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||
let _ = keyring.set_password(&j);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_private_key(id: String) -> Result<String, String> {
|
||||
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
|
||||
let password = keyring.get_password().map_err(|e| e.to_string())?;
|
||||
let account: Account = serde_json::from_str(&password).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(account.secret_key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn delete_account(id: String) -> Result<(), String> {
|
||||
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
|
||||
let _ = keyring.delete_credential();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn has_signer(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
match client.signer().await {
|
||||
Ok(signer) => {
|
||||
let signer_key = signer.get_public_key().await.map_err(|e| e.to_string())?;
|
||||
let is_match = signer_key == public_key;
|
||||
|
||||
Ok(is_match)
|
||||
}
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_signer(
|
||||
id: String,
|
||||
state: State<'_, Nostr>,
|
||||
handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
|
||||
|
||||
let account = match keyring.get_password() {
|
||||
Ok(pw) => {
|
||||
let account: Account = serde_json::from_str(&pw).map_err(|e| e.to_string())?;
|
||||
|
||||
if account.secret_key.is_empty() {
|
||||
return Err("Watch Only account".into());
|
||||
};
|
||||
|
||||
account
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
|
||||
match account.nostr_connect {
|
||||
None => {
|
||||
let secret_key = SecretKey::from_str(&account.secret_key).map_err(|e| e.to_string())?;
|
||||
let keys = Keys::new(secret_key);
|
||||
|
||||
// Update signer
|
||||
client.set_signer(keys).await;
|
||||
// Emit to front-end
|
||||
handle.emit("signer-updated", ()).unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Some(bunker) => {
|
||||
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
|
||||
let app_keys = Keys::from_str(&account.secret_key).map_err(|e| e.to_string())?;
|
||||
|
||||
match NostrConnect::new(uri, app_keys, Duration::from_secs(120), None) {
|
||||
Ok(mut signer) => {
|
||||
// Handle auth url
|
||||
signer.auth_url_handler(AuthHandler);
|
||||
// Update signer
|
||||
client.set_signer(signer).await;
|
||||
// Emit to front-end
|
||||
handle.emit("signer-updated", ()).unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_app_settings(state: State<'_, Nostr>) -> Result<Settings, String> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_app_settings(settings: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
|
||||
let mut settings = state.settings.write().await;
|
||||
// Update state
|
||||
settings.clone_from(&parsed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
721
src-tauri/src/commands/event.rs
Normal file
@@ -0,0 +1,721 @@
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use tauri::State;
|
||||
|
||||
use crate::common::{create_tags, parse_event, process_event, Meta};
|
||||
use crate::{Nostr, DEFAULT_DIFFICULTY, FETCH_LIMIT};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct RichEvent {
|
||||
pub raw: String,
|
||||
pub parsed: Option<Meta>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent, String> {
|
||||
let client = &state.client;
|
||||
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
||||
|
||||
let events = client
|
||||
.database()
|
||||
.event_by_id(&event_id)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
if let Some(event) = events {
|
||||
let raw = event.as_json();
|
||||
let parsed = if event.kind == Kind::TextNote {
|
||||
Some(parse_event(&event.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(RichEvent { raw, parsed })
|
||||
} else {
|
||||
let filter = Filter::new().id(event_id).limit(1);
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(5)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
if let Some(event) = events.first() {
|
||||
let raw = event.as_json();
|
||||
let parsed = if event.kind == Kind::TextNote {
|
||||
Some(parse_event(&event.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(RichEvent { raw, parsed })
|
||||
} else {
|
||||
Err("Event not found.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_meta_from_event(content: String) -> Result<Meta, ()> {
|
||||
Ok(parse_event(&content).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_replies(id: String, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::TextNote, Kind::Custom(1111)])
|
||||
.event(event_id);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events = process_event(client, events, true).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_events_by_author(
|
||||
public_key: String,
|
||||
limit: i32,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
let author = PublicKey::parse(&public_key).map_err(|err| err.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.author(author)
|
||||
.limit(limit as usize);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_events_by_authors(
|
||||
public_keys: Vec<String>,
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let authors: Vec<PublicKey> = public_keys
|
||||
.iter()
|
||||
.filter_map(|pk| PublicKey::from_str(pk).ok())
|
||||
.collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(authors)
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_events_by_hashtags(
|
||||
hashtags: Vec<String>,
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of)
|
||||
.hashtags(hashtags);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_events_from(
|
||||
url: String,
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let _ = client.add_read_relay(&url).await;
|
||||
let _ = client.connect_relay(&url).await;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events_from(vec![url], vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_events_by_kind(
|
||||
kind: u16,
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(kind))
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_providers(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(31990))
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), vec!["5300"]);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn request_events_from_provider(
|
||||
provider: String,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let signer = client.signer().await.map_err(|err| err.to_string())?;
|
||||
let public_key = signer
|
||||
.get_public_key()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
let provider = PublicKey::parse(&provider).map_err(|err| err.to_string())?;
|
||||
|
||||
// Get current user's relay list
|
||||
let relay_list = client
|
||||
.database()
|
||||
.relay_list(public_key)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let relay_list: Vec<String> = relay_list.iter().map(|item| item.0.to_string()).collect();
|
||||
|
||||
// Create job request
|
||||
let builder = EventBuilder::job_request(
|
||||
Kind::JobRequest(5300),
|
||||
vec![
|
||||
Tag::public_key(provider),
|
||||
Tag::custom(TagKind::Relays, relay_list),
|
||||
],
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
match client.send_event_builder(builder).await {
|
||||
Ok(output) => {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::JobResult(6300))
|
||||
.author(provider)
|
||||
.pubkey(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2)));
|
||||
|
||||
let _ = client.subscribe(vec![filter], Some(opts)).await;
|
||||
|
||||
Ok(output.val.to_hex())
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_events_by_request(
|
||||
id: String,
|
||||
provider: String,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(&id).map_err(|err| err.to_string())?;
|
||||
let provider = PublicKey::parse(&provider).map_err(|err| err.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::JobResult(6300))
|
||||
.author(provider)
|
||||
.pubkey(public_key)
|
||||
.limit(1);
|
||||
|
||||
let events = client
|
||||
.database()
|
||||
.query(vec![filter])
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
if let Some(event) = events.first() {
|
||||
let parsed: Vec<Vec<String>> =
|
||||
serde_json::from_str(&event.content).map_err(|err| err.to_string())?;
|
||||
|
||||
let vec: Vec<Tag> = parsed
|
||||
.into_iter()
|
||||
.filter_map(|item| Tag::parse(&item).ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let tags = Tags::new(vec);
|
||||
let ids: Vec<EventId> = tags.event_ids().copied().collect();
|
||||
|
||||
let filter = Filter::new().ids(ids);
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
} else {
|
||||
Err("Job result not found.".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_local_events(
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(process_event(client, events, false).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_global_events(
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(process_event(client, events, false).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn publish(
|
||||
content: String,
|
||||
warning: Option<String>,
|
||||
difficulty: Option<u8>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
|
||||
// Create event tags from content
|
||||
let mut tags = create_tags(&content);
|
||||
|
||||
// Add client tag
|
||||
// TODO: allow user config this setting
|
||||
tags.push(Tag::custom(TagKind::custom("client"), vec!["Lume"]));
|
||||
|
||||
// Add content-warning tag if present
|
||||
if let Some(reason) = warning {
|
||||
let t = TagStandard::ContentWarning {
|
||||
reason: Some(reason),
|
||||
};
|
||||
let tag = Tag::from_standardized(t);
|
||||
tags.push(tag)
|
||||
};
|
||||
|
||||
// Create unsigned event
|
||||
let builder =
|
||||
EventBuilder::text_note(content, tags).pow(difficulty.unwrap_or(DEFAULT_DIFFICULTY));
|
||||
|
||||
// Sign event
|
||||
let event = client
|
||||
.sign_event_builder(builder)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// Save to local database
|
||||
match client.send_event(event).await {
|
||||
Ok(output) => Ok(output.to_hex()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn reply(content: String, to: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
|
||||
// Create event tags from content
|
||||
let mut tags = create_tags(&content);
|
||||
|
||||
// Add client tag
|
||||
// TODO: allow user config this setting
|
||||
tags.push(Tag::custom(TagKind::custom("client"), vec!["Lume"]));
|
||||
|
||||
// Get reply event
|
||||
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
|
||||
|
||||
let reply_to = match client.database().event_by_id(&reply_id).await {
|
||||
Ok(event) => {
|
||||
if let Some(event) = event {
|
||||
event
|
||||
} else {
|
||||
return Err("Event not found in database, cannot reply.".into());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
|
||||
// Detect root event from reply
|
||||
let root_ids: Vec<&EventId> = reply_to
|
||||
.tags
|
||||
.filter_standardized(TagKind::e())
|
||||
.filter_map(|t| match t {
|
||||
TagStandard::Event {
|
||||
event_id, marker, ..
|
||||
} => {
|
||||
if let Some(mkr) = marker {
|
||||
match mkr {
|
||||
Marker::Root => Some(event_id),
|
||||
Marker::Reply => Some(event_id),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
Some(event_id)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Get root event if exist
|
||||
let root = match root_ids.first() {
|
||||
Some(&id) => client
|
||||
.database()
|
||||
.event_by_id(id)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
let builder = EventBuilder::text_note_reply(content, &reply_to, root.as_ref(), None)
|
||||
.add_tags(tags)
|
||||
.pow(DEFAULT_DIFFICULTY);
|
||||
|
||||
// Sign event
|
||||
let event = client
|
||||
.sign_event_builder(builder)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
match client.send_event(event).await {
|
||||
Ok(output) => Ok(output.to_hex()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repost(raw: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let event = Event::from_json(raw).map_err(|err| err.to_string())?;
|
||||
|
||||
match client.repost(&event, None).await {
|
||||
Ok(output) => Ok(output.to_hex()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn is_reposted(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
let signer = client.signer().await.map_err(|err| err.to_string())?;
|
||||
let public_key = signer
|
||||
.get_public_key()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.event(event_id)
|
||||
.kind(Kind::Repost)
|
||||
.author(public_key);
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(!events.is_empty()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
||||
|
||||
match client.delete_event(event_id).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
let signer = client.signer().await.map_err(|err| err.to_string())?;
|
||||
let public_key = signer
|
||||
.get_public_key()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.event(event_id)
|
||||
.kind(Kind::EventDeletion);
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(!events.is_empty()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
||||
|
||||
let seens = client
|
||||
.database()
|
||||
.event_seen_on_relays(&event_id)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
match seens {
|
||||
Some(set) => {
|
||||
let relays = set.into_iter().collect::<Vec<_>>();
|
||||
let event = Nip19Event::new(event_id, relays);
|
||||
|
||||
match event.to_bech32() {
|
||||
Ok(id) => Ok(id),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
None => match event_id.to_bech32() {
|
||||
Ok(id) => Ok(id),
|
||||
Err(err) => Err(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn user_to_bech32(user: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(user).map_err(|err| err.to_string())?;
|
||||
|
||||
match client
|
||||
.database()
|
||||
.query(vec![Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::RelayList)
|
||||
.limit(1)])
|
||||
.await
|
||||
{
|
||||
Ok(events) => match events.first() {
|
||||
Some(event) => {
|
||||
let relay_list = nip65::extract_relay_list(event);
|
||||
let relays = relay_list
|
||||
.into_iter()
|
||||
.map(|i| i.0.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let profile =
|
||||
Nip19Profile::new(public_key, relays).map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(profile.to_bech32().map_err(|err| err.to_string())?)
|
||||
}
|
||||
None => match public_key.to_bech32() {
|
||||
Ok(pk) => Ok(pk),
|
||||
Err(err) => Err(err.to_string()),
|
||||
},
|
||||
},
|
||||
Err(_) => match public_key.to_bech32() {
|
||||
Ok(pk) => Ok(pk),
|
||||
Err(err) => Err(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn search(query: String, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
let filter = Filter::new().search(query);
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(process_event(client, events, false).await),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
623
src-tauri/src/commands/metadata.rs
Normal file
@@ -0,0 +1,623 @@
|
||||
use keyring::Entry;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use tauri::State;
|
||||
|
||||
use crate::{common::process_event, Nostr, RichEvent, FETCH_LIMIT};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
pub struct Mention {
|
||||
pubkey: String,
|
||||
avatar: String,
|
||||
display_name: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_profile(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::Metadata)
|
||||
.limit(1);
|
||||
|
||||
let events = client
|
||||
.database()
|
||||
.query(vec![filter])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match events.first() {
|
||||
Some(event) => match Metadata::from_json(&event.content) {
|
||||
Ok(metadata) => Ok(metadata.as_json()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
None => Err("Metadata not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_contact_list(
|
||||
public_keys: Vec<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
let contact_list: Vec<Contact> = public_keys
|
||||
.into_iter()
|
||||
.filter_map(|p| match PublicKey::parse(p) {
|
||||
Ok(pk) => Some(Contact::new(pk, None, Some(""))),
|
||||
Err(_) => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
match client.set_contact_list(contact_list).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_contact_list(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let contact_list = client
|
||||
.database()
|
||||
.contacts_public_keys(public_key)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let pubkeys: Vec<String> = contact_list.into_iter().map(|pk| pk.to_hex()).collect();
|
||||
|
||||
Ok(pubkeys)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_profile(new_profile: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let metadata = Metadata::from_json(new_profile).map_err(|e| e.to_string())?;
|
||||
|
||||
match client.set_metadata(&metadata).await {
|
||||
Ok(id) => Ok(id.to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn is_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
match client.database().contacts_public_keys(public_key).await {
|
||||
Ok(public_keys) => Ok(public_keys.iter().any(|i| i == &public_key)),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn toggle_contact(
|
||||
id: String,
|
||||
alias: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
|
||||
match client.get_contact_list(Some(Duration::from_secs(5))).await {
|
||||
Ok(mut contact_list) => {
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
match contact_list.iter().position(|x| x.public_key == public_key) {
|
||||
Some(index) => {
|
||||
// Remove contact
|
||||
contact_list.remove(index);
|
||||
}
|
||||
None => {
|
||||
// TODO: Add relay_url
|
||||
let new_contact = Contact::new(public_key, None, alias);
|
||||
// Add new contact
|
||||
contact_list.push(new_contact);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish
|
||||
match client.set_contact_list(contact_list).await {
|
||||
Ok(event_id) => Ok(event_id.to_string()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_group(
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
image: Option<String>,
|
||||
users: Vec<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let public_keys: Vec<PublicKey> = users
|
||||
.iter()
|
||||
.filter_map(|u| {
|
||||
if let Ok(pk) = PublicKey::from_str(u) {
|
||||
Some(pk)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let label = title.to_lowercase().replace(" ", "-");
|
||||
let mut tags: Vec<Tag> = vec![Tag::title(title)];
|
||||
|
||||
if let Some(desc) = description {
|
||||
tags.push(Tag::description(desc))
|
||||
};
|
||||
|
||||
if let Some(img) = image {
|
||||
let url = UncheckedUrl::new(img);
|
||||
tags.push(Tag::image(url, None));
|
||||
}
|
||||
|
||||
let builder = EventBuilder::follow_set(label, public_keys.clone()).add_tags(tags);
|
||||
|
||||
// Sign event
|
||||
let event = client
|
||||
.sign_event_builder(builder)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
match client.send_event(event).await {
|
||||
Ok(output) => Ok(output.to_hex()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_group(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
match client.database().event_by_id(&event_id).await {
|
||||
Ok(event) => {
|
||||
if let Some(ev) = event {
|
||||
Ok(ev.as_json())
|
||||
} else {
|
||||
Err("Event not found".into())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_newsfeeds(
|
||||
id: String,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let groups = Filter::new().kind(Kind::FollowSet).author(public_key);
|
||||
let contacts = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let mut remote_events = Events::new(&[groups.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![groups], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
remote_events.insert(event);
|
||||
}
|
||||
|
||||
let contact_events = client
|
||||
.fetch_events(vec![contacts], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let events = remote_events.merge(contact_events);
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_local_newsfeeds(
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::FollowSet)
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
let events = client
|
||||
.database()
|
||||
.query(vec![filter])
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_interest(
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
image: Option<String>,
|
||||
hashtags: Vec<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let label = title.to_lowercase().replace(" ", "-");
|
||||
let mut tags: Vec<Tag> = vec![Tag::title(title)];
|
||||
|
||||
if let Some(desc) = description {
|
||||
tags.push(Tag::description(desc))
|
||||
};
|
||||
|
||||
if let Some(img) = image {
|
||||
let url = UncheckedUrl::new(img);
|
||||
tags.push(Tag::image(url, None));
|
||||
}
|
||||
|
||||
let builder = EventBuilder::interest_set(label, hashtags.clone()).add_tags(tags);
|
||||
|
||||
// Sign event
|
||||
let event = client
|
||||
.sign_event_builder(builder)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
match client.send_event(event).await {
|
||||
Ok(output) => Ok(output.to_hex()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_interest(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
match client.database().event_by_id(&event_id).await {
|
||||
Ok(event) => {
|
||||
if let Some(ev) = event {
|
||||
Ok(ev.as_json())
|
||||
} else {
|
||||
Err("Event not found".into())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_interests(
|
||||
id: String,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::InterestSet, Kind::Interests])
|
||||
.author(public_key);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_local_interests(
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::Interests, Kind::InterestSet])
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
let events = client
|
||||
.database()
|
||||
.query(vec![filter])
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_relay_list(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::RelayList)
|
||||
.limit(1);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
if let Some(event) = events.first() {
|
||||
Ok(event.as_json())
|
||||
} else {
|
||||
Err("Relay list not found".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_profiles(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
|
||||
let client = &state.client;
|
||||
let filter = Filter::new().kind(Kind::Metadata);
|
||||
|
||||
let events = client
|
||||
.database()
|
||||
.query(vec![filter])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let data: Vec<Mention> = events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
let pubkey = event.pubkey.to_bech32().unwrap();
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or(Metadata::new());
|
||||
|
||||
Mention {
|
||||
pubkey,
|
||||
avatar: metadata.picture.unwrap_or_else(|| "".to_string()),
|
||||
display_name: metadata.display_name.unwrap_or_else(|| "".to_string()),
|
||||
name: metadata.name.unwrap_or_else(|| "".to_string()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
|
||||
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) {
|
||||
let nwc = NWC::new(nwc_uri);
|
||||
let keyring =
|
||||
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||
|
||||
keyring.set_password(uri).map_err(|e| e.to_string())?;
|
||||
client.set_zapper(nwc).await;
|
||||
|
||||
Ok(true)
|
||||
} else {
|
||||
Err("Set NWC failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn load_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
|
||||
if client.zapper().await.is_err() {
|
||||
let keyring =
|
||||
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||
|
||||
match keyring.get_password() {
|
||||
Ok(val) => {
|
||||
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
|
||||
let nwc = NWC::new(uri);
|
||||
|
||||
client.set_zapper(nwc).await;
|
||||
}
|
||||
Err(_) => return Err("Wallet not found.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
let keyring = Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||
|
||||
match keyring.delete_credential() {
|
||||
Ok(_) => {
|
||||
client.unset_zapper().await;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn zap_profile(
|
||||
id: String,
|
||||
amount: String,
|
||||
message: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
|
||||
let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?;
|
||||
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
||||
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
|
||||
|
||||
match client.zap(public_key, num, details).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn zap_event(
|
||||
id: String,
|
||||
amount: String,
|
||||
message: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
|
||||
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
||||
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
||||
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
|
||||
|
||||
match client.zap(event_id, num, details).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
|
||||
match PublicKey::from_bech32(npub) {
|
||||
Ok(author) => {
|
||||
let mut contact_list: Vec<Contact> = Vec::new();
|
||||
let contact_list_filter = Filter::new()
|
||||
.author(author)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
if let Ok(contact_list_events) = client
|
||||
.fetch_events(vec![contact_list_filter], Some(Duration::from_secs(5)))
|
||||
.await
|
||||
{
|
||||
for event in contact_list_events.into_iter() {
|
||||
for tag in event.tags.into_iter() {
|
||||
if let Some(TagStandard::PublicKey {
|
||||
public_key,
|
||||
relay_url,
|
||||
alias,
|
||||
uppercase: false,
|
||||
}) = tag.to_standardized()
|
||||
{
|
||||
contact_list.push(Contact::new(public_key, relay_url, alias))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match client.set_contact_list(contact_list).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_notifications(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.pubkey(public_key)
|
||||
.kinds(vec![
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::Reaction,
|
||||
Kind::ZapReceipt,
|
||||
])
|
||||
.limit(500);
|
||||
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
let alt_events = events.into_iter().map(|ev| ev.as_json()).collect();
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {
|
||||
match PublicKey::from_str(&id) {
|
||||
Ok(public_key) => match nip05::verify(&public_key, nip05, None).await {
|
||||
Ok(status) => Ok(status),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod account;
|
||||
pub mod event;
|
||||
pub mod metadata;
|
||||
pub mod relay;
|
||||
pub mod window;
|
||||
82
src-tauri/src/commands/relay.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::str::FromStr;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{Nostr, FETCH_LIMIT};
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
pub struct Relays {
|
||||
connected: Vec<String>,
|
||||
read: Option<Vec<String>>,
|
||||
write: Option<Vec<String>>,
|
||||
both: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_relays(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||
let client = &state.client;
|
||||
let relays = client.pool().all_relays().await;
|
||||
let v: Vec<String> = relays.iter().map(|item| item.0.to_string()).collect();
|
||||
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_relay_lists(
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
let events = client
|
||||
.database()
|
||||
.query(vec![filter])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn is_relay_connected(relay: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
let status = client.add_relay(&relay).await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn connect_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
let _ = client.add_relay(&relay).await;
|
||||
let _ = client.connect_relay(&relay).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remove_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
let _ = client.force_remove_relay(relay).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
248
src-tauri/src/commands/window.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
use tauri::{
|
||||
utils::config::WindowEffectsConfig, webview::PageLoadEvent, window::Effect, LogicalPosition,
|
||||
LogicalSize, Manager, WebviewBuilder, WebviewUrl, WebviewWindowBuilder, Window,
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri_plugin_decorum::WebviewWindowExt;
|
||||
|
||||
use crate::common::get_last_segment;
|
||||
use crate::Nostr;
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
pub struct NewWindow {
|
||||
label: String,
|
||||
title: String,
|
||||
url: String,
|
||||
width: f64,
|
||||
height: f64,
|
||||
maximizable: bool,
|
||||
minimizable: bool,
|
||||
hidden_title: bool,
|
||||
closable: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
pub struct Column {
|
||||
label: String,
|
||||
url: String,
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn create_column(
|
||||
column: Column,
|
||||
app_handle: tauri::AppHandle,
|
||||
main_window: Window,
|
||||
) -> Result<String, String> {
|
||||
match app_handle.get_webview(&column.label) {
|
||||
Some(webview) => {
|
||||
if let Err(e) = webview.set_size(LogicalSize::new(column.width, column.height)) {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
if let Err(e) = webview.set_position(LogicalPosition::new(column.x, column.y)) {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
Ok(column.label)
|
||||
}
|
||||
None => {
|
||||
let path = PathBuf::from(column.url);
|
||||
let webview_url = WebviewUrl::App(path);
|
||||
|
||||
let builder = WebviewBuilder::new(column.label, webview_url)
|
||||
.incognito(true)
|
||||
.transparent(true)
|
||||
.on_page_load(|webview, payload| match payload.event() {
|
||||
PageLoadEvent::Started => {
|
||||
if let Ok(id) = get_last_segment(payload.url()) {
|
||||
if let Ok(public_key) = PublicKey::parse(&id) {
|
||||
let is_newsfeed = payload.url().to_string().contains("newsfeed");
|
||||
|
||||
if is_newsfeed {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = webview.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
|
||||
if let Ok(contact_list) =
|
||||
client.database().contacts_public_keys(public_key).await
|
||||
{
|
||||
let subscription_id =
|
||||
SubscriptionId::new(webview.label());
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(contact_list)
|
||||
.kinds(vec![
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::EventDeletion,
|
||||
])
|
||||
.since(Timestamp::now());
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id(
|
||||
subscription_id,
|
||||
vec![filter],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
println!("Subscription error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Ok(event_id) = EventId::parse(&id) {
|
||||
let is_thread = payload.url().to_string().contains("events");
|
||||
|
||||
if is_thread {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = webview.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
|
||||
let subscription_id = SubscriptionId::new(webview.label());
|
||||
|
||||
let filter = Filter::new()
|
||||
.event(event_id)
|
||||
.kinds(vec![Kind::TextNote, Kind::Custom(1111)])
|
||||
.since(Timestamp::now());
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id(subscription_id, vec![filter], None)
|
||||
.await
|
||||
{
|
||||
println!("Subscription error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PageLoadEvent::Finished => {
|
||||
println!("{} finished loading", payload.url());
|
||||
}
|
||||
});
|
||||
|
||||
match main_window.add_child(
|
||||
builder,
|
||||
LogicalPosition::new(column.x, column.y),
|
||||
LogicalSize::new(column.width, column.height),
|
||||
) {
|
||||
Ok(webview) => Ok(webview.label().into()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn close_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
match app_handle.get_webview(&label) {
|
||||
Some(webview) => Ok(webview.close().is_ok()),
|
||||
None => Err(format!("Cannot close, column not found: {}", label)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn close_all_columns(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let mut webviews = app_handle.webviews();
|
||||
webviews.remove("main");
|
||||
|
||||
for webview in webviews.values() {
|
||||
webview.close().unwrap()
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
match app_handle.get_webview(&label) {
|
||||
Some(webview) => {
|
||||
webview.eval("location.reload(true)").unwrap();
|
||||
Ok(())
|
||||
}
|
||||
None => Err("Cannot reload, column not found.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn open_window(window: NewWindow, app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
if let Some(current_window) = app_handle.get_window(&window.label) {
|
||||
if current_window.is_visible().unwrap_or_default() {
|
||||
let _ = current_window.set_focus();
|
||||
} else {
|
||||
let _ = current_window.show();
|
||||
let _ = current_window.set_focus();
|
||||
};
|
||||
|
||||
Ok(current_window.label().to_string())
|
||||
} else {
|
||||
#[cfg(target_os = "macos")]
|
||||
let new_window = WebviewWindowBuilder::new(
|
||||
&app_handle,
|
||||
&window.label,
|
||||
WebviewUrl::App(PathBuf::from(window.url)),
|
||||
)
|
||||
.title(&window.title)
|
||||
.min_inner_size(window.width, window.height)
|
||||
.inner_size(window.width, window.height)
|
||||
.hidden_title(window.hidden_title)
|
||||
.title_bar_style(TitleBarStyle::Overlay)
|
||||
.minimizable(window.minimizable)
|
||||
.maximizable(window.maximizable)
|
||||
.transparent(true)
|
||||
.closable(window.closable)
|
||||
.effects(WindowEffectsConfig {
|
||||
state: None,
|
||||
effects: vec![Effect::UnderWindowBackground],
|
||||
radius: None,
|
||||
color: None,
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let new_window = WebviewWindowBuilder::new(
|
||||
&app_handle,
|
||||
&window.label,
|
||||
WebviewUrl::App(PathBuf::from(window.url)),
|
||||
)
|
||||
.title(&window.title)
|
||||
.min_inner_size(window.width, window.height)
|
||||
.inner_size(window.width, window.height)
|
||||
.minimizable(window.minimizable)
|
||||
.maximizable(window.maximizable)
|
||||
.transparent(true)
|
||||
.decorations(false)
|
||||
.closable(window.closable)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// Set decoration
|
||||
#[cfg(target_os = "windows")]
|
||||
new_window.create_overlay_titlebar().unwrap();
|
||||
|
||||
// Restore native border
|
||||
#[cfg(target_os = "macos")]
|
||||
new_window.add_border(None);
|
||||
|
||||
Ok(new_window.label().to_string())
|
||||
}
|
||||
}
|
||||
305
src-tauri/src/common.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
use futures::future::join_all;
|
||||
use keyring_search::{Limit, List, Search};
|
||||
use linkify::LinkFinder;
|
||||
use nostr_sdk::prelude::*;
|
||||
use reqwest::Client as ReqClient;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::{collections::HashSet, str::FromStr};
|
||||
|
||||
use crate::RichEvent;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct Meta {
|
||||
pub content: String,
|
||||
pub images: Vec<String>,
|
||||
pub events: Vec<String>,
|
||||
pub mentions: Vec<String>,
|
||||
pub hashtags: Vec<String>,
|
||||
}
|
||||
|
||||
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
||||
// const VIDEOS: [&str; 6] = ["mp4", "avi", "mov", "mkv", "wmv", "webm"];
|
||||
|
||||
const NOSTR_EVENTS: [&str; 10] = [
|
||||
"@nevent1",
|
||||
"@note1",
|
||||
"@nostr:note1",
|
||||
"@nostr:nevent1",
|
||||
"nostr:note1",
|
||||
"note1",
|
||||
"nostr:nevent1",
|
||||
"nevent1",
|
||||
"Nostr:note1",
|
||||
"Nostr:nevent1",
|
||||
];
|
||||
|
||||
const NOSTR_MENTIONS: [&str; 8] = [
|
||||
"@npub1",
|
||||
"nostr:npub1",
|
||||
"nostr:nprofile1",
|
||||
"npub1",
|
||||
"nprofile1",
|
||||
"Nostr:npub1",
|
||||
"Nostr:nprofile1",
|
||||
"Nostr:naddr1",
|
||||
];
|
||||
|
||||
pub fn get_latest_event(events: &Events) -> Option<&Event> {
|
||||
events.iter().next()
|
||||
}
|
||||
|
||||
pub fn create_tags(content: &str) -> Vec<Tag> {
|
||||
let mut tags: Vec<Tag> = vec![];
|
||||
let mut tag_set: HashSet<String> = HashSet::new();
|
||||
|
||||
// Get words
|
||||
let words: Vec<_> = content.split_whitespace().collect();
|
||||
|
||||
// Get mentions
|
||||
let mentions = words
|
||||
.iter()
|
||||
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Get hashtags
|
||||
let hashtags = words
|
||||
.iter()
|
||||
.filter(|&&word| word.starts_with('#'))
|
||||
.map(|&s| s.to_string().replace("#", "").to_lowercase())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for mention in mentions {
|
||||
let entity = mention.replace("nostr:", "").replace('@', "");
|
||||
|
||||
if !tag_set.contains(&entity) {
|
||||
if entity.starts_with("npub") {
|
||||
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
|
||||
let tag = Tag::public_key(public_key);
|
||||
tags.push(tag);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if entity.starts_with("nprofile") {
|
||||
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
|
||||
let tag = Tag::public_key(public_key);
|
||||
tags.push(tag);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if entity.starts_with("note") {
|
||||
if let Ok(event_id) = EventId::from_bech32(&entity) {
|
||||
let hex = event_id.to_hex();
|
||||
let tag = Tag::parse(&["e", &hex, "", "mention"]).unwrap();
|
||||
tags.push(tag);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if entity.starts_with("nevent") {
|
||||
if let Ok(event) = Nip19Event::from_bech32(&entity) {
|
||||
let hex = event.event_id.to_hex();
|
||||
let relay = event.clone().relays.into_iter().next().unwrap_or("".into());
|
||||
let tag = Tag::parse(&["e", &hex, &relay, "mention"]).unwrap();
|
||||
|
||||
if let Some(author) = event.author {
|
||||
let tag = Tag::public_key(author);
|
||||
tags.push(tag);
|
||||
}
|
||||
|
||||
tags.push(tag);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
tag_set.insert(entity);
|
||||
}
|
||||
}
|
||||
|
||||
for hashtag in hashtags {
|
||||
if !tag_set.contains(&hashtag) {
|
||||
let tag = Tag::hashtag(hashtag.clone());
|
||||
tags.push(tag);
|
||||
tag_set.insert(hashtag);
|
||||
}
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
pub fn get_all_accounts() -> Vec<String> {
|
||||
let search = Search::new().expect("Unexpected.");
|
||||
let results = search.by_service("Lume Safe Storage");
|
||||
let list = List::list_credentials(&results, Limit::All);
|
||||
let accounts: HashSet<String> = list
|
||||
.split_whitespace()
|
||||
.filter(|v| v.starts_with("npub1") && !v.ends_with("Lume"))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
accounts.into_iter().collect()
|
||||
}
|
||||
|
||||
pub fn get_last_segment(url: &Url) -> Result<String, String> {
|
||||
url.path_segments()
|
||||
.ok_or("No segments".to_string())?
|
||||
.last()
|
||||
.ok_or("No items".into())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
pub async fn process_event(client: &Client, events: Events, is_reply: bool) -> Vec<RichEvent> {
|
||||
// Remove event thread if event is TextNote
|
||||
let events: Vec<Event> = if !is_reply {
|
||||
events
|
||||
.into_iter()
|
||||
.filter_map(|ev| {
|
||||
if ev.kind == Kind::TextNote {
|
||||
let tags = ev
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.is_reply() || t.is_root())
|
||||
.filter_map(|t| t.content())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if tags.is_empty() {
|
||||
Some(ev)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(ev)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
events.into_iter().collect()
|
||||
};
|
||||
|
||||
// Get deletion request by event's authors
|
||||
let ids: Vec<EventId> = events.iter().map(|ev| ev.id).collect();
|
||||
let filter = Filter::new().events(ids).kind(Kind::EventDeletion);
|
||||
|
||||
let mut final_events: Vec<Event> = events.clone();
|
||||
|
||||
if let Ok(requests) = client.database().query(vec![filter]).await {
|
||||
if !requests.is_empty() {
|
||||
let ids: Vec<&str> = requests
|
||||
.iter()
|
||||
.flat_map(|event| {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.kind() == TagKind::e())
|
||||
.filter_map(|t| t.content())
|
||||
.collect::<Vec<&str>>()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Remove event if event is deleted by author
|
||||
final_events = events
|
||||
.into_iter()
|
||||
.filter_map(|ev| {
|
||||
if ids.iter().any(|&i| i == ev.id.to_hex()) {
|
||||
None
|
||||
} else {
|
||||
Some(ev)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
|
||||
// Convert raw event to rich event
|
||||
let futures = final_events.iter().map(|ev| async move {
|
||||
let raw = ev.as_json();
|
||||
let parsed = if ev.kind == Kind::TextNote {
|
||||
Some(parse_event(&ev.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
RichEvent { raw, parsed }
|
||||
});
|
||||
|
||||
join_all(futures).await
|
||||
}
|
||||
|
||||
pub async fn parse_event(content: &str) -> Meta {
|
||||
let mut finder = LinkFinder::new();
|
||||
finder.url_must_have_scheme(false);
|
||||
|
||||
// Get urls
|
||||
let urls: Vec<_> = finder.links(content).collect();
|
||||
// Get words
|
||||
let words: Vec<_> = content.split_whitespace().collect();
|
||||
|
||||
let hashtags = words
|
||||
.iter()
|
||||
.filter(|&&word| word.starts_with('#'))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let events = words
|
||||
.iter()
|
||||
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mentions = words
|
||||
.iter()
|
||||
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut images = Vec::new();
|
||||
let mut text = content.to_string();
|
||||
|
||||
if !urls.is_empty() {
|
||||
let client = ReqClient::new();
|
||||
|
||||
for url in urls {
|
||||
let url_str = url.as_str();
|
||||
|
||||
if let Ok(parsed_url) = Url::from_str(url_str) {
|
||||
if let Some(ext) = parsed_url
|
||||
.path_segments()
|
||||
.and_then(|segments| segments.last().and_then(|s| s.split('.').last()))
|
||||
{
|
||||
if IMAGES.contains(&ext) {
|
||||
text = text.replace(url_str, "");
|
||||
images.push(url_str.to_string());
|
||||
// Process the next item.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the content type of URL via HEAD request
|
||||
if let Ok(res) = client.head(url_str).send().await {
|
||||
if let Some(content_type) = res.headers().get("Content-Type") {
|
||||
if content_type.to_str().unwrap_or("").starts_with("image") {
|
||||
text = text.replace(url_str, "");
|
||||
images.push(url_str.to_string());
|
||||
// Process the next item.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the resulting content string to remove extra spaces
|
||||
let cleaned_text = text.trim().to_string();
|
||||
|
||||
Meta {
|
||||
content: cleaned_text,
|
||||
events,
|
||||
mentions,
|
||||
hashtags,
|
||||
images,
|
||||
}
|
||||
}
|
||||
@@ -1,149 +1,637 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
||||
use commands::{account::*, event::*, metadata::*, relay::*, window::*};
|
||||
use common::{get_all_accounts, parse_event};
|
||||
use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use specta_typescript::Typescript;
|
||||
use std::{collections::HashSet, fs, str::FromStr, time::Duration};
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
Emitter, EventTarget, Listener, Manager, WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_decorum::WebviewWindowExt;
|
||||
use tauri_plugin_notification::{NotificationExt, PermissionState};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tauri_specta::{collect_commands, Builder};
|
||||
use tokio::{sync::RwLock, time::sleep};
|
||||
|
||||
// use rand::distributions::{Alphanumeric, DistString};
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
pub mod commands;
|
||||
pub mod common;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use window_ext::WindowExt;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod window_ext;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct Payload {
|
||||
args: Vec<String>,
|
||||
cwd: String,
|
||||
pub struct Nostr {
|
||||
client: Client,
|
||||
queue: RwLock<HashSet<PublicKey>>,
|
||||
settings: RwLock<Settings>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Payload {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct Settings {
|
||||
resize_service: bool,
|
||||
content_warning: bool,
|
||||
display_avatar: bool,
|
||||
display_zap_button: bool,
|
||||
display_repost_button: bool,
|
||||
display_media: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
content_warning: true,
|
||||
resize_service: true,
|
||||
display_avatar: true,
|
||||
display_zap_button: true,
|
||||
display_repost_button: true,
|
||||
display_media: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const DEFAULT_DIFFICULTY: u8 = 0;
|
||||
pub const FETCH_LIMIT: usize = 50;
|
||||
pub const QUEUE_DELAY: u64 = 150;
|
||||
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
|
||||
// Will be removed when almost relays support negentropy
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://nostr.fmt.wiz.biz",
|
||||
"wss://directory.yabu.me",
|
||||
"wss://purplepag.es",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
let main_window = app.get_window("main").unwrap();
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
main_window.position_traffic_lights(13.0, 17.0); // set inset for traffic lights (macos)
|
||||
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||
get_all_relays,
|
||||
get_all_relay_lists,
|
||||
is_relay_connected,
|
||||
connect_relay,
|
||||
remove_relay,
|
||||
get_accounts,
|
||||
watch_account,
|
||||
import_account,
|
||||
connect_account,
|
||||
get_private_key,
|
||||
delete_account,
|
||||
reset_password,
|
||||
has_signer,
|
||||
set_signer,
|
||||
get_profile,
|
||||
set_profile,
|
||||
get_contact_list,
|
||||
set_contact_list,
|
||||
is_contact,
|
||||
toggle_contact,
|
||||
get_all_profiles,
|
||||
set_group,
|
||||
get_group,
|
||||
get_all_newsfeeds,
|
||||
get_all_local_newsfeeds,
|
||||
set_interest,
|
||||
get_interest,
|
||||
get_all_interests,
|
||||
get_all_local_interests,
|
||||
get_relay_list,
|
||||
set_wallet,
|
||||
load_wallet,
|
||||
remove_wallet,
|
||||
zap_profile,
|
||||
zap_event,
|
||||
copy_friend,
|
||||
get_notifications,
|
||||
verify_nip05,
|
||||
get_meta_from_event,
|
||||
get_event,
|
||||
get_replies,
|
||||
get_all_events_by_author,
|
||||
get_all_events_by_authors,
|
||||
get_all_events_by_hashtags,
|
||||
get_all_events_from,
|
||||
get_all_events_by_kind,
|
||||
get_all_providers,
|
||||
request_events_from_provider,
|
||||
get_all_events_by_request,
|
||||
get_local_events,
|
||||
get_global_events,
|
||||
search,
|
||||
publish,
|
||||
reply,
|
||||
repost,
|
||||
is_reposted,
|
||||
request_delete,
|
||||
is_deleted_event,
|
||||
event_to_bech32,
|
||||
user_to_bech32,
|
||||
create_column,
|
||||
reload_column,
|
||||
close_column,
|
||||
close_all_columns,
|
||||
open_window,
|
||||
get_app_settings,
|
||||
set_app_settings,
|
||||
]);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|e| {
|
||||
#[cfg(target_os = "macos")]
|
||||
let apply_offset = || {
|
||||
let win = e.window();
|
||||
// keep inset for traffic lights when window resize (macos)
|
||||
win.position_traffic_lights(13.0, 17.0);
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
match e.event() {
|
||||
WindowEvent::Resized(..) => apply_offset(),
|
||||
WindowEvent::ThemeChanged(..) => apply_offset(),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.plugin(
|
||||
tauri_plugin_sql::Builder::default()
|
||||
.add_migrations(
|
||||
"sqlite:lume.db",
|
||||
vec![
|
||||
Migration {
|
||||
version: 20230418013219,
|
||||
description: "initial data",
|
||||
sql: include_str!("../migrations/20230418013219_initial_data.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 20230418080146,
|
||||
description: "create chats",
|
||||
sql: include_str!("../migrations/20230418080146_create_chats.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 20230420040005,
|
||||
description: "insert last login to settings",
|
||||
sql: include_str!("../migrations/20230420040005_insert_last_login_to_settings.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 20230425023912,
|
||||
description: "add pubkey to channel",
|
||||
sql: include_str!("../migrations/20230425023912_add_pubkey_to_channel.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 20230425024708,
|
||||
description: "add default channels",
|
||||
sql: include_str!("../migrations/20230425024708_add_default_channels.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 20230425050745,
|
||||
description: "create blacklist",
|
||||
sql: include_str!("../migrations/20230425050745_add_blacklist_model.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 20230521092300,
|
||||
description: "create block",
|
||||
sql: include_str!("../migrations/20230521092300_add_block_model.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 20230617003135,
|
||||
description: "add channel messages",
|
||||
sql: include_str!("../migrations/20230617003135_add_channel_messages.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 20230619082415,
|
||||
description: "add replies",
|
||||
sql: include_str!("../migrations/20230619082415_add_replies.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.plugin(
|
||||
tauri_plugin_stronghold::Builder::new(|password| {
|
||||
let config = argon2::Config {
|
||||
lanes: 2,
|
||||
mem_cost: 50_000,
|
||||
time_cost: 30,
|
||||
thread_mode: argon2::ThreadMode::from_threads(2),
|
||||
variant: argon2::Variant::Argon2id,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(debug_assertions)]
|
||||
builder
|
||||
.export(Typescript::default(), "../src/commands.gen.ts")
|
||||
.expect("Failed to export typescript bindings");
|
||||
|
||||
// let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12);
|
||||
let key = argon2::hash_raw(
|
||||
password.as_ref(),
|
||||
b"LUME_NEED_RUST_DEVELOPER_HELP_MAKE_SALT_RANDOM",
|
||||
&config,
|
||||
)
|
||||
.expect("failed to hash password");
|
||||
let tauri_builder = tauri::Builder::default();
|
||||
let mut ctx = tauri::generate_context!();
|
||||
|
||||
key.to_vec()
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--flag1", "--flag2"]),
|
||||
))
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
|
||||
println!("{}, {argv:?}, {cwd}", app.package_info().name);
|
||||
app
|
||||
.emit_all("single-instance", Payload { args: argv, cwd })
|
||||
.unwrap();
|
||||
}))
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
tauri_builder
|
||||
.invoke_handler(builder.invoke_handler())
|
||||
.setup(move |app| {
|
||||
let handle = app.handle();
|
||||
let handle_clone = handle.clone();
|
||||
let handle_clone_child = handle_clone.clone();
|
||||
let handle_clone_event = handle_clone_child.clone();
|
||||
let main_window = app.get_webview_window("main").unwrap();
|
||||
|
||||
let config_dir = handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.expect("Error: app config directory not found.");
|
||||
|
||||
let _ = fs::create_dir_all(&config_dir);
|
||||
|
||||
// Set custom decoration for Windows
|
||||
#[cfg(target_os = "windows")]
|
||||
main_window.create_overlay_titlebar().unwrap();
|
||||
|
||||
// Restore native border
|
||||
#[cfg(target_os = "macos")]
|
||||
main_window.add_border(None);
|
||||
|
||||
// Set a custom inset to the traffic lights
|
||||
#[cfg(target_os = "macos")]
|
||||
main_window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
||||
|
||||
// Setup tray menu item
|
||||
let open_i = MenuItem::with_id(app, "open", "Open Lume", true, None::<&str>)?;
|
||||
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
// Create tray menu
|
||||
let menu = Menu::with_items(app, &[&open_i, &quit_i])?;
|
||||
// Get main tray
|
||||
let tray = app.tray_by_id("main").unwrap();
|
||||
// Set menu
|
||||
tray.set_menu(Some(menu)).unwrap();
|
||||
// Listen to tray events
|
||||
tray.on_menu_event(|handle, event| match event.id().as_ref() {
|
||||
"open" => {
|
||||
if let Some(window) = handle.get_window("main") {
|
||||
if window.is_visible().unwrap_or_default() {
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
};
|
||||
} else {
|
||||
let window = WebviewWindowBuilder::from_config(
|
||||
handle,
|
||||
handle.config().app.windows.first().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// Set decoration
|
||||
#[cfg(target_os = "windows")]
|
||||
window.create_overlay_titlebar().unwrap();
|
||||
|
||||
// Restore native border
|
||||
#[cfg(target_os = "macos")]
|
||||
window.add_border(None);
|
||||
|
||||
// Set a custom inset to the traffic lights
|
||||
#[cfg(target_os = "macos")]
|
||||
window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let client = tauri::async_runtime::block_on(async move {
|
||||
// Setup database
|
||||
let database = NostrLMDB::open(config_dir.join("nostr"))
|
||||
.expect("Error: cannot create database.");
|
||||
|
||||
// Config
|
||||
let opts = Options::new()
|
||||
.gossip(true)
|
||||
.max_avg_latency(Duration::from_millis(500))
|
||||
.timeout(Duration::from_secs(5));
|
||||
|
||||
// Setup nostr client
|
||||
let client = ClientBuilder::default()
|
||||
.database(database)
|
||||
.opts(opts)
|
||||
.build();
|
||||
|
||||
/* Get bootstrap relays
|
||||
if let Ok(path) = handle
|
||||
.path()
|
||||
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
||||
{
|
||||
let file = std::fs::File::open(&path).unwrap();
|
||||
let lines = io::BufReader::new(file).lines();
|
||||
|
||||
// Add bootstrap relays to relay pool
|
||||
for line in lines.map_while(Result::ok) {
|
||||
if let Some((relay, option)) = line.split_once(',') {
|
||||
match RelayMetadata::from_str(option) {
|
||||
Ok(meta) => {
|
||||
let opts = if meta == RelayMetadata::Read {
|
||||
RelayOptions::new().read(true).write(false)
|
||||
} else {
|
||||
RelayOptions::new().write(true).read(false)
|
||||
};
|
||||
let _ = client.pool().add_relay(relay, opts).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = client.add_relay(relay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
for relay in BOOTSTRAP_RELAYS {
|
||||
let _ = client.add_relay(relay).await;
|
||||
}
|
||||
|
||||
let _ = client.add_discovery_relay("wss://user.kindpag.es/").await;
|
||||
|
||||
// Connect
|
||||
client.connect().await;
|
||||
|
||||
client
|
||||
});
|
||||
|
||||
// Load app settings
|
||||
let store = app.store(".data")?;
|
||||
|
||||
// Parse app settings if exist
|
||||
let settings = if let Some(data) = store.get("tanstack-query-[\"settings\"]") {
|
||||
if let Some(str) = data.as_str() {
|
||||
let v: Value = serde_json::from_str(str).unwrap();
|
||||
let data = v["state"]["data"].clone();
|
||||
let parse: Settings = serde_json::from_value(data).unwrap();
|
||||
|
||||
RwLock::new(parse)
|
||||
} else {
|
||||
RwLock::new(Settings::default())
|
||||
}
|
||||
} else {
|
||||
RwLock::new(Settings::default())
|
||||
};
|
||||
|
||||
// Create global state
|
||||
app.manage(Nostr {
|
||||
client,
|
||||
settings,
|
||||
queue: RwLock::new(HashSet::new()),
|
||||
});
|
||||
|
||||
// Listen for request metadata
|
||||
app.listen_any("request_metadata", move |event| {
|
||||
let payload = event.payload();
|
||||
let parsed_payload: Payload = serde_json::from_str(payload).expect("Parse failed");
|
||||
let handle = handle_clone_child.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = handle.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
|
||||
if let Ok(public_key) = PublicKey::parse(parsed_payload.id) {
|
||||
let mut write_queue = state.queue.write().await;
|
||||
write_queue.insert(public_key);
|
||||
};
|
||||
|
||||
// Wait for [QUEUE_DELAY]
|
||||
sleep(Duration::from_millis(QUEUE_DELAY)).await;
|
||||
|
||||
let read_queue = state.queue.read().await;
|
||||
|
||||
if !read_queue.is_empty() {
|
||||
let authors: HashSet<PublicKey> = read_queue.iter().copied().collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(authors)
|
||||
.kind(Kind::Metadata)
|
||||
.limit(200);
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2)));
|
||||
|
||||
// Drop queue, you don't need it at this time anymore
|
||||
drop(read_queue);
|
||||
// Clear queue
|
||||
let mut write_queue = state.queue.write().await;
|
||||
write_queue.clear();
|
||||
|
||||
if let Err(e) = client.subscribe(vec![filter], Some(opts)).await {
|
||||
println!("Subscribe error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Run a thread for negentropy
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = handle_clone_event.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
|
||||
// Use default sync options
|
||||
let opts = SyncOptions::default();
|
||||
|
||||
// Set interval
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(600));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let accounts = get_all_accounts();
|
||||
let public_keys: Vec<PublicKey> = accounts
|
||||
.iter()
|
||||
.filter_map(|acc| PublicKey::from_str(acc).ok())
|
||||
.collect();
|
||||
|
||||
if !public_keys.is_empty() {
|
||||
// Create filter for notification
|
||||
//
|
||||
let filter = Filter::new().pubkeys(public_keys.clone()).kinds(vec![
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::Reaction,
|
||||
Kind::ZapReceipt,
|
||||
]);
|
||||
|
||||
// Sync notification
|
||||
//
|
||||
if let Ok(output) = client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
|
||||
{
|
||||
println!("Received: {}", output.received.len())
|
||||
}
|
||||
|
||||
// Create filter for contact list
|
||||
//
|
||||
let filter = Filter::new()
|
||||
.authors(public_keys)
|
||||
.kinds(vec![Kind::ContactList, Kind::FollowSet]);
|
||||
|
||||
// Sync events for contact list
|
||||
//
|
||||
if let Ok(events) = client.database().query(vec![filter]).await {
|
||||
// Get unique public keys
|
||||
let public_keys: HashSet<PublicKey> = events
|
||||
.iter()
|
||||
.flat_map(|ev| ev.tags.public_keys().copied())
|
||||
.collect();
|
||||
|
||||
// Convert to vector
|
||||
let public_keys: Vec<PublicKey> = public_keys.into_iter().collect();
|
||||
|
||||
for chunk in public_keys.chunks(1000) {
|
||||
if chunk.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let authors = chunk.to_owned();
|
||||
|
||||
// Create filter for metadata
|
||||
//
|
||||
let filter = Filter::new().authors(authors.clone()).kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::FollowSet,
|
||||
Kind::Interests,
|
||||
Kind::InterestSet,
|
||||
]);
|
||||
|
||||
// Sync metadata
|
||||
//
|
||||
if let Ok(output) =
|
||||
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
|
||||
{
|
||||
println!("Received: {}", output.received.len())
|
||||
}
|
||||
|
||||
// Create filter for text note
|
||||
//
|
||||
let filter = Filter::new()
|
||||
.authors(authors)
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost, Kind::EventDeletion])
|
||||
.limit(100);
|
||||
|
||||
// Sync text note
|
||||
//
|
||||
if let Ok(output) =
|
||||
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
|
||||
{
|
||||
println!("Received: {}", output.received.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run a thread for handle notification
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = handle_clone.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
let accounts = get_all_accounts();
|
||||
|
||||
if !accounts.is_empty() {
|
||||
let subscription_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
||||
|
||||
let public_keys: Vec<PublicKey> = accounts
|
||||
.iter()
|
||||
.filter_map(|acc| PublicKey::from_str(acc).ok())
|
||||
.collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.pubkeys(public_keys)
|
||||
.kinds(vec![
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::Reaction,
|
||||
Kind::ZapReceipt,
|
||||
Kind::Custom(1111),
|
||||
])
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Subscribe for new notification
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id(subscription_id, vec![filter], None)
|
||||
.await
|
||||
{
|
||||
println!("Subscribe error: {}", e)
|
||||
}
|
||||
}
|
||||
|
||||
let allow_notification = match handle_clone.notification().request_permission() {
|
||||
Ok(_) => {
|
||||
if let Ok(perm) = handle_clone.notification().permission_state() {
|
||||
PermissionState::Granted == perm
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
||||
|
||||
let _ = client
|
||||
.handle_notifications(|notification| async {
|
||||
#[allow(clippy::collapsible_match)]
|
||||
if let RelayPoolNotification::Message { message, .. } = notification {
|
||||
if let RelayMessage::Event {
|
||||
event,
|
||||
subscription_id,
|
||||
..
|
||||
} = message
|
||||
{
|
||||
if subscription_id == notification_id {
|
||||
// Send native notification
|
||||
if allow_notification {
|
||||
let author = client
|
||||
.database()
|
||||
.profile(event.pubkey)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
DatabaseProfile::new(event.pubkey, Metadata::new())
|
||||
});
|
||||
|
||||
send_event_notification(
|
||||
&event,
|
||||
author.metadata(),
|
||||
&handle_clone,
|
||||
);
|
||||
}
|
||||
} else if event.kind == Kind::Metadata {
|
||||
if let Err(e) = handle_clone.emit("metadata", event.as_json()) {
|
||||
println!("Emit error: {}", e)
|
||||
}
|
||||
} else if event.kind == Kind::TextNote {
|
||||
let payload = RichEvent {
|
||||
raw: event.as_json(),
|
||||
parsed: if event.kind == Kind::TextNote {
|
||||
Some(parse_event(&event.content).await)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(e) = handle_clone.emit_to(
|
||||
EventTarget::labeled(subscription_id.to_string()),
|
||||
"event",
|
||||
payload,
|
||||
) {
|
||||
println!("Emit error: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(prevent_default())
|
||||
.plugin(tauri_plugin_theme::init(ctx.config_mut()))
|
||||
.plugin(tauri_plugin_decorum::init())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_upload::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.build(ctx)
|
||||
.expect("error while running tauri application")
|
||||
.run(|_app_handle, event| {
|
||||
if let tauri::RunEvent::ExitRequested { api, .. } = event {
|
||||
api.prevent_exit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn prevent_default() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
use tauri_plugin_prevent_default::Flags;
|
||||
|
||||
tauri_plugin_prevent_default::Builder::new()
|
||||
.with_flags(Flags::all().difference(Flags::CONTEXT_MENU))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
fn prevent_default() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
tauri_plugin_prevent_default::Builder::new().build()
|
||||
}
|
||||
|
||||
fn send_event_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
|
||||
match event.kind {
|
||||
Kind::TextNote => {
|
||||
if let Err(e) = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.body("Mentioned you in a thread.")
|
||||
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
||||
.show()
|
||||
{
|
||||
println!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
Kind::Repost => {
|
||||
if let Err(e) = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.body("Reposted your note.")
|
||||
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
||||
.show()
|
||||
{
|
||||
println!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
Kind::ZapReceipt => {
|
||||
if let Err(e) = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.body("Zapped you.")
|
||||
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
||||
.show()
|
||||
{
|
||||
println!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool);
|
||||
fn position_traffic_lights(&self, x: f64, y: f64);
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for Window<R> {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool) {
|
||||
use cocoa::appkit::{NSWindow, NSWindowTitleVisibility};
|
||||
|
||||
let window = self.ns_window().unwrap() as cocoa::base::id;
|
||||
|
||||
unsafe {
|
||||
window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
|
||||
|
||||
if transparent {
|
||||
window.setTitlebarAppearsTransparent_(cocoa::base::YES);
|
||||
} else {
|
||||
window.setTitlebarAppearsTransparent_(cocoa::base::NO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(&self, x: f64, y: f64) {
|
||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
||||
use cocoa::foundation::NSRect;
|
||||
|
||||
let window = self.ns_window().unwrap() as cocoa::base::id;
|
||||
|
||||
unsafe {
|
||||
let close = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
|
||||
let miniaturize = window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
|
||||
let zoom = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
|
||||
|
||||
let title_bar_container_view = close.superview().superview();
|
||||
|
||||
let close_rect: NSRect = msg_send![close, frame];
|
||||
let button_height = close_rect.size.height;
|
||||
|
||||
let title_bar_frame_height = button_height + y;
|
||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||
title_bar_rect.size.height = title_bar_frame_height;
|
||||
title_bar_rect.origin.y = NSView::frame(window).size.height - title_bar_frame_height;
|
||||
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
|
||||
|
||||
let window_buttons = vec![close, miniaturize, zoom];
|
||||
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
|
||||
|
||||
for (i, button) in window_buttons.into_iter().enumerate() {
|
||||
let mut rect: NSRect = NSView::frame(button);
|
||||
rect.origin.x = x + (i as f64 * space_between);
|
||||
button.setFrameOrigin(rect.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,20 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Lume",
|
||||
"version": "24.11.6",
|
||||
"identifier": "nu.lume.Lume",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:3000",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"devPath": "http://localhost:3000",
|
||||
"distDir": "../dist",
|
||||
"withGlobalTauri": true
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"package": {
|
||||
"productName": "Lume",
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false,
|
||||
"app": {
|
||||
"all": false
|
||||
},
|
||||
"os": {
|
||||
"all": true
|
||||
},
|
||||
"http": {
|
||||
"all": true,
|
||||
"request": true,
|
||||
"scope": ["http://**", "https://**"]
|
||||
},
|
||||
"fs": {
|
||||
"all": false,
|
||||
"readFile": true,
|
||||
"readDir": true,
|
||||
"app": {
|
||||
"macOSPrivateApi": true,
|
||||
"withGlobalTauri": true,
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": [
|
||||
"$APPDATA/*",
|
||||
"$DATA/*",
|
||||
@@ -38,108 +25,59 @@
|
||||
"$HOME/*",
|
||||
"$PICTURE/*",
|
||||
"$PUBLIC/*",
|
||||
"$VIDEO/*"
|
||||
"$VIDEO/*",
|
||||
"$APPCONFIG/*",
|
||||
"$RESOURCE/*"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"all": true
|
||||
},
|
||||
"shell": {
|
||||
"all": false,
|
||||
"open": true
|
||||
},
|
||||
"clipboard": {
|
||||
"all": false,
|
||||
"writeText": true,
|
||||
"readText": true
|
||||
},
|
||||
"dialog": {
|
||||
"all": false,
|
||||
"open": true
|
||||
},
|
||||
"notification": {
|
||||
"all": true
|
||||
},
|
||||
"window": {
|
||||
"startDragging": true,
|
||||
"close": true
|
||||
},
|
||||
"process": {
|
||||
"all": false,
|
||||
"exit": false,
|
||||
"relaunch": true,
|
||||
"relaunchDangerousAllowSymlinkMacos": false
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "SocialNetworking",
|
||||
"copyright": "",
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
},
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.lume.nu",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
"trayIcon": {
|
||||
"id": "main",
|
||||
"iconAsTemplate": true,
|
||||
"menuOnLeftClick": true,
|
||||
"tooltip": "Lume",
|
||||
"iconPath": "./icons/tray.png"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"homepage": "https://lume.nu",
|
||||
"longDescription": "nostr client for desktop",
|
||||
"shortDescription": "nostr client",
|
||||
"targets": "all",
|
||||
"active": true,
|
||||
"category": "SocialNetworking",
|
||||
"resources": ["resources/*", "locales/*"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"allowDowngrades": true,
|
||||
"webviewInstallMode": {
|
||||
"silent": true,
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": "upgrade-insecure-requests"
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
},
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"dialog": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU3OTdCMkM3RjU5QzE2NzkKUldSNUZwejF4N0tYNTVHYjMrU0JkL090SlEyNUVLYU5TM2hTU3RXSWtEWngrZWJ4a0pydUhXZHEK",
|
||||
"windows": {
|
||||
"installMode": "quiet"
|
||||
},
|
||||
"endpoints": [
|
||||
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
|
||||
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU4RjAzODFBREQ4MkM3RTEKUldUaHg0TGRHamp3NkI5bnhoOEVjanlHWFNzQ2Q3NDhubFFLUmJpSHJ1L2FqNnB3alF1Y2R3U3gK",
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
},
|
||||
"systemTray": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": true
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "Lume",
|
||||
"theme": "Dark",
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"transparent": false,
|
||||
"fullscreen": false,
|
||||
"resizable": true,
|
||||
"width": 1080,
|
||||
"height": 800,
|
||||
"minWidth": 1080,
|
||||
"minHeight": 720
|
||||
}
|
||||
]
|
||||
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}",
|
||||
"https://github.com/lumehq/lume/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
src-tauri/tauri.macos.conf.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Lume",
|
||||
"label": "main",
|
||||
"titleBarStyle": "Overlay",
|
||||
"width": 900,
|
||||
"height": 700,
|
||||
"minWidth": 480,
|
||||
"minHeight": 700,
|
||||
"hiddenTitle": true,
|
||||
"transparent": true,
|
||||
"windowEffects": {
|
||||
"effects": ["underWindowBackground"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
17
src-tauri/tauri.windows.conf.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Lume",
|
||||
"label": "main",
|
||||
"width": 900,
|
||||
"height": 700,
|
||||
"minWidth": 480,
|
||||
"minHeight": 700,
|
||||
"transparent": true,
|
||||
"decorations": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
126
src/app.css
Normal file
@@ -0,0 +1,126 @@
|
||||
@tailwind base;
|
||||
@tailwind utilities;
|
||||
@tailwind components;
|
||||
|
||||
@layer utilities {
|
||||
.content-break {
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.shadow-toolbar {
|
||||
box-shadow:
|
||||
0 0 #0000,
|
||||
0 0 #0000,
|
||||
0 8px 24px 0 rgba(0, 0, 0, 0.2),
|
||||
0 2px 8px 0 rgba(0, 0, 0, 0.08),
|
||||
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
|
||||
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
|
||||
}
|
||||
|
||||
.shadow-primary {
|
||||
box-shadow: 0px 0px 4px rgba(66, 65, 73, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Overide some default styles
|
||||
*/
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply cursor-default no-underline !important;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-default focus:outline-none;
|
||||
}
|
||||
|
||||
input::-ms-reveal,
|
||||
input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::-webkit-input-placeholder {
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
div[data-tauri-decorum-tb] {
|
||||
@apply h-10 !important;
|
||||
}
|
||||
|
||||
button.decorum-tb-btn {
|
||||
@apply h-10 !important;
|
||||
}
|
||||
|
||||
.spinner-leaf {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(50% - 12.5% / 2);
|
||||
width: 12.5%;
|
||||
height: 100%;
|
||||
animation: spinner-leaf-fade 800ms linear infinite;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
background-color: currentColor;
|
||||
@apply rounded;
|
||||
}
|
||||
|
||||
&:where(:nth-child(1)) {
|
||||
transform: rotate(0deg);
|
||||
animation-delay: -800ms;
|
||||
}
|
||||
|
||||
&:where(:nth-child(2)) {
|
||||
transform: rotate(45deg);
|
||||
animation-delay: -700ms;
|
||||
}
|
||||
|
||||
&:where(:nth-child(3)) {
|
||||
transform: rotate(90deg);
|
||||
animation-delay: -600ms;
|
||||
}
|
||||
|
||||
&:where(:nth-child(4)) {
|
||||
transform: rotate(135deg);
|
||||
animation-delay: -500ms;
|
||||
}
|
||||
|
||||
&:where(:nth-child(5)) {
|
||||
transform: rotate(180deg);
|
||||
animation-delay: -400ms;
|
||||
}
|
||||
|
||||
&:where(:nth-child(6)) {
|
||||
transform: rotate(225deg);
|
||||
animation-delay: -300ms;
|
||||
}
|
||||
|
||||
&:where(:nth-child(7)) {
|
||||
transform: rotate(270deg);
|
||||
animation-delay: -200ms;
|
||||
}
|
||||
|
||||
&:where(:nth-child(8)) {
|
||||
transform: rotate(315deg);
|
||||
animation-delay: -100ms;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-leaf-fade {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
172
src/app.tsx
@@ -1,115 +1,63 @@
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
|
||||
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
import { Store } from "@tauri-apps/plugin-store";
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { newQueryStorage } from "./commons";
|
||||
import type { LumeEvent } from "./system";
|
||||
|
||||
import { AuthCreateScreen } from '@app/auth/create';
|
||||
import { CreateStep1Screen } from '@app/auth/create/step-1';
|
||||
import { CreateStep2Screen } from '@app/auth/create/step-2';
|
||||
import { CreateStep3Screen } from '@app/auth/create/step-3';
|
||||
import { CreateStep4Screen } from '@app/auth/create/step-4';
|
||||
import { CreateStep5Screen } from '@app/auth/create/step-5';
|
||||
import { AuthImportScreen } from '@app/auth/import';
|
||||
import { ImportStep1Screen } from '@app/auth/import/step-1';
|
||||
import { ImportStep2Screen } from '@app/auth/import/step-2';
|
||||
import { ImportStep3Screen } from '@app/auth/import/step-3';
|
||||
import { MigrateScreen } from '@app/auth/migrate';
|
||||
import { OnboardingScreen } from '@app/auth/onboarding';
|
||||
import { UnlockScreen } from '@app/auth/unlock';
|
||||
import { WelcomeScreen } from '@app/auth/welcome';
|
||||
import { ChannelScreen } from '@app/channel';
|
||||
import { ChatScreen } from '@app/chat';
|
||||
import { ErrorScreen } from '@app/error';
|
||||
import { NoteScreen } from '@app/note';
|
||||
import { Root } from '@app/root';
|
||||
import { AccountSettingsScreen } from '@app/settings/account';
|
||||
import { GeneralSettingsScreen } from '@app/settings/general';
|
||||
import { ShortcutsSettingsScreen } from '@app/settings/shortcuts';
|
||||
import { SpaceScreen } from '@app/space';
|
||||
import { TrendingScreen } from '@app/trending';
|
||||
import { UserScreen } from '@app/user';
|
||||
import { routeTree } from "./routes.gen"; // auto generated file
|
||||
import "./app.css"; // global styles
|
||||
|
||||
import { AppLayout } from '@shared/appLayout';
|
||||
import { AuthLayout } from '@shared/authLayout';
|
||||
import { Protected } from '@shared/protected';
|
||||
import { SettingsLayout } from '@shared/settingsLayout';
|
||||
|
||||
import './index.css';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<Protected>
|
||||
<Root />
|
||||
</Protected>
|
||||
),
|
||||
errorElement: <ErrorScreen />,
|
||||
},
|
||||
{
|
||||
path: '/auth',
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{ path: 'welcome', element: <WelcomeScreen /> },
|
||||
{ path: 'onboarding', element: <OnboardingScreen /> },
|
||||
{
|
||||
path: 'import',
|
||||
element: <AuthImportScreen />,
|
||||
children: [
|
||||
{ path: '', element: <ImportStep1Screen /> },
|
||||
{ path: 'step-2', element: <ImportStep2Screen /> },
|
||||
{ path: 'step-3', element: <ImportStep3Screen /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
element: <AuthCreateScreen />,
|
||||
children: [
|
||||
{ path: '', element: <CreateStep1Screen /> },
|
||||
{ path: 'step-2', element: <CreateStep2Screen /> },
|
||||
{ path: 'step-3', element: <CreateStep3Screen /> },
|
||||
{ path: 'step-4', element: <CreateStep4Screen /> },
|
||||
{ path: 'step-5', element: <CreateStep5Screen /> },
|
||||
],
|
||||
},
|
||||
{ path: 'unlock', element: <UnlockScreen /> },
|
||||
{ path: 'migrate', element: <MigrateScreen /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/app',
|
||||
element: (
|
||||
<Protected>
|
||||
<AppLayout />
|
||||
</Protected>
|
||||
),
|
||||
children: [
|
||||
{ path: 'space', element: <SpaceScreen /> },
|
||||
{ path: 'trending', element: <TrendingScreen /> },
|
||||
{ path: 'note/:id', element: <NoteScreen /> },
|
||||
{ path: 'user/:pubkey', element: <UserScreen /> },
|
||||
{ path: 'chat/:pubkey', element: <ChatScreen /> },
|
||||
{ path: 'channel/:id', element: <ChannelScreen /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: (
|
||||
<Protected>
|
||||
<SettingsLayout />
|
||||
</Protected>
|
||||
),
|
||||
children: [
|
||||
{ path: 'general', element: <GeneralSettingsScreen /> },
|
||||
{ path: 'shortcuts', element: <ShortcutsSettingsScreen /> },
|
||||
{ path: 'account', element: <AccountSettingsScreen /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<RouterProvider
|
||||
router={router}
|
||||
fallbackElement={<p>Loading..</p>}
|
||||
future={{ v7_startTransition: true }}
|
||||
/>
|
||||
);
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
interface HistoryState {
|
||||
events?: LumeEvent[];
|
||||
}
|
||||
}
|
||||
|
||||
const platform = type();
|
||||
// @ts-ignore, won't fix
|
||||
const store = await Store.load(".data", { autoSave: 300 });
|
||||
const storage = newQueryStorage(store);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 1000 * 20, // 20 seconds
|
||||
persister: experimental_createPersister({
|
||||
storage: storage,
|
||||
maxAge: 1000 * 60 * 60 * 6, // 6 hours
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Make sure all webviews use same query client
|
||||
broadcastQueryClient({
|
||||
queryClient,
|
||||
broadcastChannel: "lume",
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { store, queryClient, platform },
|
||||
Wrap: ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
const root = ReactDOM.createRoot(rootElement as unknown as HTMLElement);
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
|
||||
export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
|
||||
const { status, user } = useProfile(pubkey, fallback);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||
<span className="h-4 w-1/2 animate-pulse rounded bg-zinc-800" />
|
||||
<span className="h-3 w-1/3 animate-pulse rounded bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-10 w-10 shrink rounded-md">
|
||||
<Image
|
||||
src={user.picture || user.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-10 w-10 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-100">
|
||||
{user.name || user.displayName || user.display_name}
|
||||
</span>
|
||||
<span className="text-base leading-tight text-zinc-400">
|
||||
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export function AuthCreateScreen() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { createAccount } from '@libs/storage';
|
||||
|
||||
import { Button } from '@shared/button';
|
||||
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
|
||||
export function CreateStep1Screen() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [setPubkey, setPrivkey] = useOnboarding((state) => [
|
||||
state.setPubkey,
|
||||
state.setPrivkey,
|
||||
]);
|
||||
const [privkeyInput, setPrivkeyInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const privkey = useMemo(() => generatePrivateKey(), []);
|
||||
const pubkey = getPublicKey(privkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const nsec = nip19.nsecEncode(privkey);
|
||||
|
||||
// toggle private key
|
||||
const showPrivateKey = () => {
|
||||
if (privkeyInput === 'password') {
|
||||
setPrivkeyInput('text');
|
||||
} else {
|
||||
setPrivkeyInput('password');
|
||||
}
|
||||
};
|
||||
|
||||
const account = useMutation({
|
||||
mutationFn: (data: {
|
||||
npub: string;
|
||||
pubkey: string;
|
||||
follows: null | string[][];
|
||||
is_active: number;
|
||||
}) => {
|
||||
return createAccount(data.npub, data.pubkey, null, 1);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['currentAccount'], data);
|
||||
},
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
setLoading(true);
|
||||
|
||||
setPubkey(pubkey);
|
||||
setPrivkey(privkey);
|
||||
|
||||
account.mutate({
|
||||
npub,
|
||||
pubkey,
|
||||
follows: null,
|
||||
is_active: 1,
|
||||
});
|
||||
|
||||
// redirect to next step
|
||||
setTimeout(() => navigate('/auth/create/step-2', { replace: true }), 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Lume is auto-generated key for you
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-semibold text-zinc-400">Public Key</span>
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-semibold text-zinc-400">Private Key</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
readOnly
|
||||
type={privkeyInput}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPrivateKey()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{privkeyInput === 'password' ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button preset="large" onClick={() => submit()}>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
|
||||
|
||||
type FormValues = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
const resolver: Resolver<FormValues> = async (values) => {
|
||||
return {
|
||||
values: values.password ? values : {},
|
||||
errors: !values.password
|
||||
? {
|
||||
password: {
|
||||
type: 'required',
|
||||
message: 'This is required.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export function CreateStep2Screen() {
|
||||
const navigate = useNavigate();
|
||||
const setPassword = useStronghold((state) => state.setPassword);
|
||||
|
||||
const [pubkey, privkey] = useOnboarding((state) => [state.pubkey, state.privkey]);
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { save } = useSecureStorage();
|
||||
|
||||
// toggle private key
|
||||
const showPassword = () => {
|
||||
if (passwordInput === 'password') {
|
||||
setPasswordInput('text');
|
||||
} else {
|
||||
setPasswordInput('password');
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
setLoading(true);
|
||||
if (data.password.length > 3) {
|
||||
// add password to local state
|
||||
setPassword(data.password);
|
||||
|
||||
// save privkey to secure storage
|
||||
await save(pubkey, privkey, data.password);
|
||||
|
||||
// redirect to next step
|
||||
navigate('/auth/create/step-3', { replace: true });
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError('password', {
|
||||
type: 'custom',
|
||||
message: 'Password is required and must be greater than 3',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Set password to secure your key
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('password', { required: true })}
|
||||
type={passwordInput}
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPassword()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{passwordInput === 'password' ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
<p>
|
||||
Password is use to secure your key store in local machine, when you move
|
||||
to other clients, you just need to copy your private key as nsec or
|
||||
hexstring
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">
|
||||
{errors.password && <p>{errors.password.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AvatarUploader } from '@shared/avatarUploader';
|
||||
import { BannerUploader } from '@shared/bannerUploader';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
|
||||
export function CreateStep3Screen() {
|
||||
const navigate = useNavigate();
|
||||
const createProfile = useOnboarding((state) => state.createProfile);
|
||||
|
||||
const [picture, setPicture] = useState(DEFAULT_AVATAR);
|
||||
const [banner, setBanner] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const profile = {
|
||||
...data,
|
||||
username: data.name,
|
||||
display_name: data.name,
|
||||
bio: data.about,
|
||||
};
|
||||
createProfile(profile);
|
||||
// redirect to next step
|
||||
setTimeout(() => navigate('/auth/create/step-4', { replace: true }), 1200);
|
||||
} catch {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Create your profile</h1>
|
||||
</div>
|
||||
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
|
||||
<input
|
||||
type={'hidden'}
|
||||
{...register('picture')}
|
||||
value={picture}
|
||||
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<input
|
||||
type={'hidden'}
|
||||
{...register('banner')}
|
||||
value={banner}
|
||||
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="relative h-44 w-full bg-zinc-800">
|
||||
<Image
|
||||
src={banner}
|
||||
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
|
||||
alt="user's banner"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
||||
<BannerUploader setBanner={setBanner} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5 px-4">
|
||||
<div className="relative z-10 -mt-7 h-14 w-14">
|
||||
<Image
|
||||
src={picture}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt="user's avatar"
|
||||
className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
||||
<AvatarUploader setPicture={setPicture} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
||||
>
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('name', {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="about"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
||||
>
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
||||
>
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('website', {
|
||||
required: false,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Body, fetch } from '@tauri-apps/api/http';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@shared/button';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { usePublish } from '@utils/hooks/usePublish';
|
||||
|
||||
export function CreateStep4Screen() {
|
||||
const navigate = useNavigate();
|
||||
const publish = usePublish();
|
||||
const profile = useOnboarding((state) => state.profile);
|
||||
|
||||
const { account } = useAccount();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const createNIP05 = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch('https://lume.nu/api/user-create', {
|
||||
method: 'POST',
|
||||
timeout: 30,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: Body.json({
|
||||
username: username,
|
||||
pubkey: account.pubkey,
|
||||
lightningAddress: '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = { ...profile, nip05: `${username}@lume.nu` };
|
||||
publish({ content: JSON.stringify(data), kind: 0, tags: [] });
|
||||
|
||||
// redirect to step 4
|
||||
navigate('/auth/create/step-5', { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Create your Lume ID</h1>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4">
|
||||
<div className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-800">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoCapitalize="false"
|
||||
autoCorrect="none"
|
||||
spellCheck="false"
|
||||
placeholder="satoshi"
|
||||
className="relative w-full bg-transparent py-3 pl-3.5 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
<span className="pr-3.5 font-semibold text-fuchsia-500">@lume.nu</span>
|
||||
</div>
|
||||
<Button
|
||||
preset="large"
|
||||
onClick={() => createNIP05()}
|
||||
disabled={username.length === 0}
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { User } from '@app/auth/components/user';
|
||||
|
||||
import { updateAccount } from '@libs/storage';
|
||||
|
||||
import { CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { usePublish } from '@utils/hooks/usePublish';
|
||||
import { arrayToNIP02 } from '@utils/transform';
|
||||
|
||||
const INITIAL_LIST = [
|
||||
{
|
||||
pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2',
|
||||
},
|
||||
{
|
||||
pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98',
|
||||
},
|
||||
{
|
||||
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
|
||||
},
|
||||
{
|
||||
pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0',
|
||||
},
|
||||
{
|
||||
pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93',
|
||||
},
|
||||
{
|
||||
pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411',
|
||||
},
|
||||
{
|
||||
pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
|
||||
},
|
||||
{
|
||||
pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15',
|
||||
},
|
||||
{
|
||||
pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42',
|
||||
},
|
||||
{
|
||||
pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240',
|
||||
},
|
||||
{
|
||||
pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898',
|
||||
},
|
||||
{
|
||||
pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce',
|
||||
},
|
||||
{
|
||||
pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0',
|
||||
},
|
||||
{
|
||||
pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965',
|
||||
},
|
||||
{
|
||||
pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6',
|
||||
},
|
||||
{
|
||||
pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3',
|
||||
},
|
||||
{
|
||||
pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63',
|
||||
},
|
||||
{
|
||||
pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594',
|
||||
},
|
||||
{
|
||||
pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c',
|
||||
},
|
||||
{
|
||||
pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884',
|
||||
},
|
||||
{
|
||||
pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24',
|
||||
},
|
||||
{
|
||||
pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f',
|
||||
},
|
||||
{
|
||||
pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479',
|
||||
},
|
||||
{
|
||||
pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f',
|
||||
},
|
||||
{
|
||||
pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b',
|
||||
},
|
||||
{
|
||||
pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5',
|
||||
},
|
||||
{
|
||||
pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c',
|
||||
},
|
||||
{
|
||||
pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a',
|
||||
},
|
||||
{
|
||||
pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27',
|
||||
},
|
||||
{
|
||||
pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2',
|
||||
},
|
||||
{
|
||||
pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14',
|
||||
},
|
||||
{
|
||||
pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609',
|
||||
},
|
||||
];
|
||||
|
||||
export function CreateStep5Screen() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const publish = usePublish();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [follows, setFollows] = useState([]);
|
||||
|
||||
const { account } = useAccount();
|
||||
const { status, data } = useQuery(['trending-profiles'], async () => {
|
||||
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
|
||||
if (!res.ok) {
|
||||
throw new Error('Error');
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
|
||||
// toggle follow state
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
const arr = follows.includes(pubkey)
|
||||
? follows.filter((i) => i !== pubkey)
|
||||
: [...follows, pubkey];
|
||||
setFollows(arr);
|
||||
};
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (follows: any) => {
|
||||
return updateAccount('follows', follows, account.pubkey);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
|
||||
},
|
||||
});
|
||||
|
||||
// save follows to database then broadcast
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const tags = arrayToNIP02([...follows, account.pubkey]);
|
||||
publish({ content: '', kind: 3, tags: tags });
|
||||
|
||||
// update
|
||||
update.mutate([...follows, account.pubkey]);
|
||||
|
||||
// redirect to next step
|
||||
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
|
||||
} catch {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
const list = data ? data.profiles.concat(INITIAL_LIST) : [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Personalized your newsfeed
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
|
||||
Follow at least
|
||||
<span className="font-semibold text-fuchsia-500">
|
||||
{follows.length}/10
|
||||
</span>{' '}
|
||||
plebs
|
||||
</div>
|
||||
{status === 'loading' ? (
|
||||
<div className="inline-flex h-11 w-full items-center justify-center px-4 py-2">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
|
||||
{list.map((item: { pubkey: string; profile: { content: string } }) => (
|
||||
<button
|
||||
key={item.pubkey}
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
|
||||
>
|
||||
<User pubkey={item.pubkey} fallback={item.profile?.content} />
|
||||
{follows.includes(item.pubkey) && (
|
||||
<div>
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{follows.length >= 10 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Finish →'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export function AuthImportScreen() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { createAccount } from '@libs/storage';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
|
||||
type FormValues = {
|
||||
privkey: string;
|
||||
};
|
||||
|
||||
const resolver: Resolver<FormValues> = async (values) => {
|
||||
return {
|
||||
values: values.privkey ? values : {},
|
||||
errors: !values.privkey
|
||||
? {
|
||||
privkey: {
|
||||
type: 'required',
|
||||
message: 'This is required.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export function ImportStep1Screen() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [setPubkey, setPrivkey] = useOnboarding((state) => [
|
||||
state.setPubkey,
|
||||
state.setPrivkey,
|
||||
]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const account = useMutation({
|
||||
mutationFn: (data: {
|
||||
npub: string;
|
||||
pubkey: string;
|
||||
follows: null | string[];
|
||||
is_active: number | boolean;
|
||||
}) => {
|
||||
return createAccount(data.npub, data.pubkey, null, 1);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['currentAccount'], data);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
let privkey = data['privkey'];
|
||||
if (privkey.substring(0, 4) === 'nsec') {
|
||||
privkey = nip19.decode(privkey).data as string;
|
||||
}
|
||||
|
||||
if (typeof getPublicKey(privkey) === 'string') {
|
||||
const pubkey = getPublicKey(privkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
// use for onboarding process only
|
||||
setPubkey(pubkey);
|
||||
setPrivkey(privkey);
|
||||
|
||||
// add account to local database
|
||||
account.mutate({
|
||||
npub,
|
||||
pubkey,
|
||||
follows: null,
|
||||
is_active: 1,
|
||||
});
|
||||
|
||||
// redirect to step 2
|
||||
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
|
||||
}
|
||||
} catch (error) {
|
||||
setError('privkey', {
|
||||
type: 'custom',
|
||||
message: 'Private Key is invalid, please check again',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Import your key</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-semibold text-zinc-400">Private key</span>
|
||||
<input
|
||||
{...register('privkey', { required: true, minLength: 32 })}
|
||||
type={'password'}
|
||||
placeholder="nsec or hexstring"
|
||||
className="relative w-full rounded-lg bg-zinc-800 px-3 py-3 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
<span className="text-sm text-red-400">
|
||||
{errors.privkey && <p>{errors.privkey.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
|
||||
|
||||
type FormValues = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
const resolver: Resolver<FormValues> = async (values) => {
|
||||
return {
|
||||
values: values.password ? values : {},
|
||||
errors: !values.password
|
||||
? {
|
||||
password: {
|
||||
type: 'required',
|
||||
message: 'This is required.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export function ImportStep2Screen() {
|
||||
const navigate = useNavigate();
|
||||
const setPassword = useStronghold((state) => state.setPassword);
|
||||
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pubkey, privkey] = useOnboarding((state) => [state.pubkey, state.privkey]);
|
||||
|
||||
const { save } = useSecureStorage();
|
||||
|
||||
// toggle private key
|
||||
const showPassword = () => {
|
||||
if (passwordInput === 'password') {
|
||||
setPasswordInput('text');
|
||||
} else {
|
||||
setPasswordInput('password');
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
setLoading(true);
|
||||
if (data.password.length > 3) {
|
||||
// add password to local state
|
||||
setPassword(data.password);
|
||||
|
||||
// save privkey to secure storage
|
||||
await save(pubkey, privkey, data.password);
|
||||
|
||||
// redirect to next step
|
||||
navigate('/auth/import/step-3', { replace: true });
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError('password', {
|
||||
type: 'custom',
|
||||
message: 'Password is required and must be greater than 3, please check again',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Set password to secure your key
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('password', { required: true })}
|
||||
type={passwordInput}
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPassword()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{passwordInput === 'password' ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
<p>
|
||||
Password is use to secure your key store in local machine, when you move
|
||||
to other clients, you just need to copy your private key as nsec or
|
||||
hexstring
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">
|
||||
{errors.password && <p>{errors.password.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { User } from '@app/auth/components/user';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { updateAccount } from '@libs/storage';
|
||||
|
||||
import { Button } from '@shared/button';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { setToArray } from '@utils/transform';
|
||||
|
||||
export function ImportStep3Screen() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { ndk } = useNDK();
|
||||
const { status, account } = useAccount();
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (follows: string[]) => {
|
||||
return updateAccount('follows', follows, account.pubkey);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// show loading indicator
|
||||
setLoading(true);
|
||||
|
||||
const user = ndk.getUser({ hexpubkey: account.pubkey });
|
||||
const follows = await user.follows();
|
||||
|
||||
// follows as list
|
||||
const followsList = setToArray(follows);
|
||||
|
||||
// update
|
||||
update.mutate([...followsList, account.pubkey]);
|
||||
|
||||
// redirect to next step
|
||||
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
|
||||
} catch {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{loading ? 'Creating...' : 'Continue with'}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
|
||||
{status === 'loading' ? (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
|
||||
<div>
|
||||
<div className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<User pubkey={account.pubkey} />
|
||||
<Button preset="large" onClick={() => submit()}>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { removePrivkey } from '@libs/storage';
|
||||
|
||||
import { CheckCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
|
||||
|
||||
type FormValues = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
const resolver: Resolver<FormValues> = async (values) => {
|
||||
return {
|
||||
values: values.password ? values : {},
|
||||
errors: !values.password
|
||||
? {
|
||||
password: {
|
||||
type: 'required',
|
||||
message: 'This is required.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export function MigrateScreen() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [passwordStep, setPasswordStep] = useState({ loading: false, done: false });
|
||||
const [privkeyStep, setPrivkeyStep] = useState({ loading: false, done: false });
|
||||
|
||||
const { account } = useAccount();
|
||||
const { save } = useSecureStorage();
|
||||
|
||||
const setPassword = useStronghold((state) => state.setPassword);
|
||||
|
||||
// toggle private key
|
||||
const showPassword = () => {
|
||||
if (passwordInput === 'password') {
|
||||
setPasswordInput('text');
|
||||
} else {
|
||||
setPasswordInput('password');
|
||||
}
|
||||
};
|
||||
|
||||
const clearPrivkey = async () => {
|
||||
setPrivkeyStep((prev) => ({ ...prev, loading: true }));
|
||||
const res = await removePrivkey();
|
||||
if (res) {
|
||||
setPrivkeyStep({ done: true, loading: false });
|
||||
} else {
|
||||
setPrivkeyStep((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
setPasswordStep((prev) => ({ ...prev, loading: true }));
|
||||
if (data.password.length > 3) {
|
||||
// add password to local state
|
||||
setPassword(data.password);
|
||||
|
||||
// load private in secure storage
|
||||
try {
|
||||
await save(account.pubkey, account.privkey, data.password);
|
||||
// redirect to home
|
||||
navigate('/', { replace: true });
|
||||
} catch {
|
||||
setPasswordStep((prev) => ({ ...prev, loading: false }));
|
||||
setError('password', {
|
||||
type: 'custom',
|
||||
message: 'Wrong password',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setPasswordStep((prev) => ({ ...prev, loading: false }));
|
||||
setError('password', {
|
||||
type: 'custom',
|
||||
message: 'Password is required and must be greater than 3',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Upgrade security for your account
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-zinc-200">
|
||||
Remove plaintext privkey store in local database
|
||||
</h3>
|
||||
<div className="mt-1">
|
||||
<p className="text-sm text-zinc-400">
|
||||
You're using old Lume version which store your private key as
|
||||
plaintext in database, this is huge security risk.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-zinc-400">To secure your private key</p>
|
||||
<ul className="mt-2 list-outside list-disc pl-5 text-sm text-zinc-400">
|
||||
<li>Firstly, click button below to remove it from local database</li>
|
||||
<li>
|
||||
Then set password and Lume will put your private key in secure storage
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
readOnly
|
||||
value={privkeyStep.done ? 'Nothing to see here' : account.privkey}
|
||||
className="relative w-full rounded-lg bg-zinc-800 px-3 py-3 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-lg bg-black/10 backdrop-blur-sm">
|
||||
{privkeyStep.done ? (
|
||||
privkeyStep.loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-zinc-100" />
|
||||
) : (
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500" />
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearPrivkey()}
|
||||
className="inline-flex w-max items-center justify-center rounded bg-fuchsia-500 px-2.5 py-1.5 text-sm hover:bg-fuchsia-600"
|
||||
>
|
||||
Click to remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-zinc-200">
|
||||
Set password to protect your key
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('password', { required: true })}
|
||||
type={passwordInput}
|
||||
placeholder="min. 4 characters"
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPassword()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{passwordInput === 'password' ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">
|
||||
{errors.password && <p>{errors.password.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
{privkeyStep.done && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="mt-3 inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{passwordStep.loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { usePublish } from '@utils/hooks/usePublish';
|
||||
|
||||
export function OnboardingScreen() {
|
||||
const publish = usePublish();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { status, account } = useAccount();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// publish event
|
||||
publish({
|
||||
content:
|
||||
'Running Lume, fighting for better future, join us here: https://lume.nu',
|
||||
kind: 1,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
// redirect to home
|
||||
setTimeout(() => navigate('/', { replace: true }), 1200);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="mb-2 text-xl font-semibold text-zinc-100">
|
||||
👋 Hello, welcome you to Lume
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-300">
|
||||
You're a part of better future that we're fighting
|
||||
</p>
|
||||
<p className="text-sm text-zinc-300">
|
||||
If Lume gets your attention, please help us spread via button below
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||
<div className="h-min w-full px-5 py-3">
|
||||
{status === 'success' && (
|
||||
<User pubkey={account.pubkey} time={Math.floor(Date.now() / 1000)} />
|
||||
)}
|
||||
<div className="-mt-6 select-text whitespace-pre-line break-words pl-[49px] text-base text-zinc-100">
|
||||
<p>Running Lume, fighting for better future</p>
|
||||
<p>
|
||||
join us here:{' '}
|
||||
<a
|
||||
href="https://lume.nu"
|
||||
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
https://lume.nu
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="w-5" />
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
<span className="w-5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-5" />
|
||||
<span>Publish</span>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
|
||||
>
|
||||
Skip for now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
|
||||
|
||||
type FormValues = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
const resolver: Resolver<FormValues> = async (values) => {
|
||||
return {
|
||||
values: values.password ? values : {},
|
||||
errors: !values.password
|
||||
? {
|
||||
password: {
|
||||
type: 'required',
|
||||
message: 'This is required.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export function UnlockScreen() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [setPrivkey, setPassword] = useStronghold((state) => [
|
||||
state.setPrivkey,
|
||||
state.setPassword,
|
||||
]);
|
||||
|
||||
const { account } = useAccount();
|
||||
const { load } = useSecureStorage();
|
||||
|
||||
// toggle private key
|
||||
const showPassword = () => {
|
||||
if (passwordInput === 'password') {
|
||||
setPasswordInput('text');
|
||||
} else {
|
||||
setPasswordInput('password');
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
setLoading(true);
|
||||
if (data.password.length > 3) {
|
||||
// add password to local state
|
||||
setPassword(data.password);
|
||||
|
||||
// load private in secure storage
|
||||
try {
|
||||
const privkey = await load(account.pubkey, data.password);
|
||||
setPrivkey(privkey);
|
||||
// redirect to home
|
||||
navigate('/', { replace: true });
|
||||
} catch {
|
||||
setLoading(false);
|
||||
setError('password', {
|
||||
type: 'custom',
|
||||
message: 'Wrong password',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError('password', {
|
||||
type: 'custom',
|
||||
message: 'Password is required and must be greater than 3',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Enter password to unlock
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('password', { required: true })}
|
||||
type={passwordInput}
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPassword()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{passwordInput === 'password' ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">
|
||||
{errors.password && <p>{errors.password.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
||||
|
||||
export function WelcomeScreen() {
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-12 gap-4 px-4 py-4">
|
||||
<div className="col-span-5 flex flex-col rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||
<div className="flex h-full w-full flex-col justify-center gap-2 px-4 py-4">
|
||||
<h1 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
|
||||
Preserve your <span className="text-fuchsia-300">freedom</span>
|
||||
</h1>
|
||||
<h2 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
|
||||
Protect your <span className="text-red-300">future</span>
|
||||
</h2>
|
||||
<h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
|
||||
Stack <span className="text-orange-300">bitcoin</span>
|
||||
</h3>
|
||||
<h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
|
||||
Use <span className="text-purple-300">nostr</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-auto flex w-full flex-col gap-2 px-4 py-4">
|
||||
<Link
|
||||
to="/auth/import"
|
||||
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
<span className="w-5" />
|
||||
<span>Login with private key</span>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/create"
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-zinc-800 px-6 font-medium text-zinc-200 hover:bg-zinc-700"
|
||||
>
|
||||
Create new key
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="col-span-5 rounded-xl bg-zinc-900 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url("https://void.cat/d/Ps1b36vu5pdkEA2w75usuB")`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="col-span-2 rounded-xl bg-zinc-900 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url("https://void.cat/d/5FdJcBP5ZXKAjYqV8hpcp3")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { MutedItem } from '@app/channel/components/mutedItem';
|
||||
|
||||
import { MuteIcon } from '@shared/icons';
|
||||
|
||||
export function ChannelBlackList({ blacklist }: { blacklist: any }) {
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ring-2 ring-zinc-950 focus:outline-none ${
|
||||
open ? 'bg-zinc-800 hover:bg-zinc-700' : 'bg-zinc-900 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<MuteIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-zinc-100"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0">
|
||||
<div className="shadow-popover flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
|
||||
Your muted list
|
||||
</h3>
|
||||
<p className="text-base leading-tight text-zinc-400">
|
||||
Currently, unmute only affect locally, when you move to new client,
|
||||
muted list will loaded again
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-3 pb-3 pt-1">
|
||||
{blacklist.map((item: any) => (
|
||||
<MutedItem key={item.id} data={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fragment, useContext, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { createChannel } from '@libs/storage';
|
||||
|
||||
import { AvatarUploader } from '@shared/avatarUploader';
|
||||
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
|
||||
export function ChannelCreateModal() {
|
||||
const { ndk } = useNDK();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
|
||||
const { account } = useAccount();
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const addChannel = useMutation({
|
||||
mutationFn: (event: any) => {
|
||||
return createChannel(
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.name,
|
||||
event.picture,
|
||||
event.about,
|
||||
event.created_at
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const signer = new NDKPrivateKeySigner(account.privkey);
|
||||
ndk.signer = signer;
|
||||
|
||||
const event = new NDKEvent(ndk);
|
||||
// build event
|
||||
event.content = JSON.stringify(data);
|
||||
event.kind = 40;
|
||||
event.created_at = dateToUnix();
|
||||
event.pubkey = account.pubkey;
|
||||
event.tags = [];
|
||||
|
||||
// publish event
|
||||
event.publish();
|
||||
|
||||
// insert to database
|
||||
addChannel.mutate({
|
||||
...event,
|
||||
name: data.name,
|
||||
picture: data.picture,
|
||||
about: data.about,
|
||||
});
|
||||
|
||||
// reset form
|
||||
reset();
|
||||
|
||||
setTimeout(() => {
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
// redirect to channel page
|
||||
navigate(`/app/channel/${event.id}`);
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
console.log('error: ', e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue('picture', image);
|
||||
}, [setValue, image]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
|
||||
>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<PlusIcon width={12} height={12} className="text-zinc-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-medium text-zinc-400">Create channel</h5>
|
||||
</div>
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-none text-zinc-100"
|
||||
>
|
||||
Create channel
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
||||
Channels are freedom square, everyone can speech freely, no one can
|
||||
stop you or deceive what to speech
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mb-0 flex h-full w-full flex-col gap-4"
|
||||
>
|
||||
<input
|
||||
type={'hidden'}
|
||||
{...register('picture')}
|
||||
value={image}
|
||||
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium uppercase tracking-wider text-zinc-400">
|
||||
Picture
|
||||
</span>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<Image
|
||||
src={image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt="channel picture"
|
||||
className="relative z-10 h-11 w-11 rounded-md"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<AvatarUploader setPicture={setImage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
||||
>
|
||||
Channel name *
|
||||
</label>
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('name', {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="about"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold leading-none text-zinc-100">
|
||||
Encrypted
|
||||
</span>
|
||||
<p className="w-4/5 text-sm leading-none text-zinc-400">
|
||||
All messages are encrypted and only invited members can view and
|
||||
send message
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Create channel →'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
|
||||
|
||||
export function ChannelsListItem({ data }: { data: any }) {
|
||||
const channel = useChannelProfile(data.event_id);
|
||||
return (
|
||||
<NavLink
|
||||
to={`/app/channel/${data.event_id}`}
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
|
||||
isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<span className="text-xs text-zinc-100">#</span>
|
||||
</div>
|
||||
<div className="inline-flex w-full items-center justify-between">
|
||||
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
|
||||
<div className="flex items-center">
|
||||
{data.new_messages && (
|
||||
<span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
|
||||
{data.new_messages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ChannelCreateModal } from '@app/channel/components/createModal';
|
||||
import { ChannelsListItem } from '@app/channel/components/item';
|
||||
|
||||
import { getChannels } from '@libs/storage';
|
||||
|
||||
export function ChannelsList() {
|
||||
const {
|
||||
status,
|
||||
data: channels,
|
||||
isFetching,
|
||||
} = useQuery(
|
||||
['channels'],
|
||||
async () => {
|
||||
return await getChannels();
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{status === 'loading' ? (
|
||||
<>
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
channels.map((item: { event_id: string }) => (
|
||||
<ChannelsListItem key={item.event_id} data={item} />
|
||||
))
|
||||
)}
|
||||
{isFetching && (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
)}
|
||||
<ChannelCreateModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
|
||||
export function Member({ pubkey }: { pubkey: string }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError || isLoading ? (
|
||||
<div className="h-7 w-7 animate-pulse rounded bg-zinc-800" />
|
||||
) : (
|
||||
<Image
|
||||
className="inline-block h-7 w-7 rounded"
|
||||
src={user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Member } from '@app/channel/components/member';
|
||||
|
||||
import { getChannelUsers } from '@libs/storage';
|
||||
|
||||
export function ChannelMembers({ id }: { id: string }) {
|
||||
const { status, data, isFetching } = useQuery(['channel-members', id], async () => {
|
||||
return await getChannelUsers(id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200">
|
||||
Members
|
||||
</h5>
|
||||
<div className="mt-3 flex w-full flex-wrap gap-1.5">
|
||||
{status === 'loading' || isFetching ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
data.map((member: { pubkey: string }) => (
|
||||
<Member key={member.pubkey} pubkey={member.pubkey} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||
import { useContext, useState } from 'react';
|
||||
|
||||
import { UserReply } from '@app/channel/components/messages/userReply';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { CancelIcon, EnterIcon } from '@shared/icons';
|
||||
import { MediaUploader } from '@shared/mediaUploader';
|
||||
|
||||
import { useChannelMessages } from '@stores/channels';
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
|
||||
export function ChannelMessageForm({ channelID }: { channelID: string }) {
|
||||
const { ndk } = useNDK();
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [replyTo, closeReply] = useChannelMessages((state: any) => [
|
||||
state.replyTo,
|
||||
state.closeReply,
|
||||
]);
|
||||
|
||||
const { account } = useAccount();
|
||||
|
||||
const submit = () => {
|
||||
let tags: string[][];
|
||||
|
||||
if (replyTo.id !== null) {
|
||||
tags = [
|
||||
['e', channelID, '', 'root'],
|
||||
['e', replyTo.id, '', 'reply'],
|
||||
['p', replyTo.pubkey, ''],
|
||||
];
|
||||
} else {
|
||||
tags = [['e', channelID, '', 'root']];
|
||||
}
|
||||
|
||||
const signer = new NDKPrivateKeySigner(account.privkey);
|
||||
ndk.signer = signer;
|
||||
|
||||
const event = new NDKEvent(ndk);
|
||||
// build event
|
||||
event.content = value;
|
||||
event.kind = 42;
|
||||
event.created_at = dateToUnix();
|
||||
event.pubkey = account.pubkey;
|
||||
event.tags = tags;
|
||||
|
||||
// publish event
|
||||
event.publish();
|
||||
|
||||
// reset state
|
||||
setValue('');
|
||||
};
|
||||
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const stopReply = () => {
|
||||
closeReply();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative w-full ${replyTo.id ? 'h-36' : 'h-24'}`}>
|
||||
{replyTo.id && (
|
||||
<div className="absolute left-0 top-0 z-10 h-16 w-full p-[2px]">
|
||||
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
|
||||
<div className="flex w-full flex-col">
|
||||
<UserReply pubkey={replyTo.pubkey} />
|
||||
<div className="-mt-5 pl-[38px]">
|
||||
<div className="text-base text-zinc-100">{replyTo.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopReply()}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<CancelIcon width={12} height={12} className="text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleEnterPress}
|
||||
spellCheck={false}
|
||||
placeholder="Message"
|
||||
className={`relative ${
|
||||
replyTo.id ? 'h-36 pt-16' : 'h-24 pt-3'
|
||||
} w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-zinc-500`}
|
||||
/>
|
||||
<div className="absolute bottom-0 right-2 h-11">
|
||||
<div className="flex h-full items-center justify-end gap-3 text-zinc-500">
|
||||
<MediaUploader setState={setValue} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex items-center gap-1 text-sm leading-none"
|
||||
>
|
||||
<EnterIcon width={14} height={14} className="" />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { CancelIcon, HideIcon } from '@shared/icons';
|
||||
|
||||
import { useChannelMessages } from '@stores/channels';
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
|
||||
export function MessageHideButton({ id }: { id: string }) {
|
||||
const { ndk } = useNDK();
|
||||
const hide = useChannelMessages((state: any) => state.hideMessage);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { account } = useAccount();
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const hideMessage = () => {
|
||||
const signer = new NDKPrivateKeySigner(account.privkey);
|
||||
ndk.signer = signer;
|
||||
|
||||
const event = new NDKEvent(ndk);
|
||||
// build event
|
||||
event.content = '';
|
||||
event.kind = 43;
|
||||
event.created_at = dateToUnix();
|
||||
event.pubkey = account.pubkey;
|
||||
event.tags = [['e', id]];
|
||||
|
||||
// publish event
|
||||
event.publish();
|
||||
|
||||
// update state
|
||||
hide(id);
|
||||
|
||||
// close modal
|
||||
closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<HideIcon width={16} height={16} className="text-zinc-200" />
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Are you sure!
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="leading-tight text-zinc-400">
|
||||
This message will be hidden from your feed.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hideMessage()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { MessageHideButton } from '@app/channel/components/messages/hideButton';
|
||||
import { MessageMuteButton } from '@app/channel/components/messages/muteButton';
|
||||
import { MessageReplyButton } from '@app/channel/components/messages/replyButton';
|
||||
|
||||
import { MentionNote } from '@shared/notes/mentions/note';
|
||||
import { ImagePreview } from '@shared/notes/preview/image';
|
||||
import { LinkPreview } from '@shared/notes/preview/link';
|
||||
import { VideoPreview } from '@shared/notes/preview/video';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { parser } from '@utils/parser';
|
||||
import { LumeEvent } from '@utils/types';
|
||||
|
||||
export function ChannelMessageItem({ data }: { data: LumeEvent }) {
|
||||
const content = parser(data);
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
|
||||
<div className="-mt-[20px] pl-[49px]">
|
||||
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
|
||||
{content.parsed}
|
||||
</p>
|
||||
{Array.isArray(content.images) && content.images.length ? (
|
||||
<ImagePreview urls={content.images} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{Array.isArray(content.videos) && content.videos.length ? (
|
||||
<VideoPreview urls={content.videos} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{Array.isArray(content.links) && content.links.length ? (
|
||||
<LinkPreview urls={content.links} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{Array.isArray(content.notes) && content.notes.length ? (
|
||||
content.notes.map((note: string) => <MentionNote key={note} id={note} />)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
|
||||
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
|
||||
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
|
||||
<MessageHideButton id={data.id} />
|
||||
<MessageMuteButton pubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||