Compare commits
895 Commits
dependabot
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c6de715c4 | ||
|
|
13b3b82443 | ||
|
|
e60a460480 | ||
|
|
81667112d1 | ||
|
|
c60d7ab401 | ||
|
|
e25902b8b4 | ||
|
|
d964c7b7b3 | ||
|
|
25b2831fcc | ||
|
|
1553227e13 | ||
|
|
f04981f5b9 | ||
|
|
2662373704 | ||
|
|
526b64aec0 | ||
|
|
41a65338b5 | ||
|
|
56f0aa9fd5 | ||
|
|
89f79b999c | ||
|
|
7459a3d33a | ||
|
|
49eca495f5 | ||
|
|
96abe5f24f | ||
|
|
0ee93718da | ||
|
|
a880a92748 | ||
|
|
cd7c52eda0 | ||
|
|
ef6d44d112 | ||
|
|
2925c0c5f9 | ||
|
|
5705d8c9b3 | ||
|
|
944246b582 | ||
|
|
163f3212d8 | ||
|
|
1193c81c78 | ||
|
|
ddb88bf074 | ||
|
|
079a2f90ef | ||
|
|
c4881e3435 | ||
|
|
bc7fb4c79b | ||
|
|
7677beef82 | ||
|
|
6cf78992a6 | ||
|
|
ce2976e3f9 | ||
|
|
dbcb48d599 | ||
|
|
4eb68d36d4 | ||
|
|
36f6d810ac | ||
|
|
9f428aed8c | ||
|
|
1f779775a0 | ||
|
|
62c7c7eb5c | ||
|
|
2422d24d68 | ||
|
|
ddcaa71cae | ||
|
|
4d7c55862c | ||
|
|
51fc7d4c05 | ||
|
|
f6f974adc6 | ||
|
|
c2b0e6f666 | ||
|
|
a6d6a19199 | ||
|
|
ac196cd662 | ||
|
|
d90348dd97 | ||
|
|
4e77975179 | ||
|
|
21ab8bc46c | ||
|
|
47958c2b23 | ||
|
|
6bcab6d563 | ||
|
|
881dedb6b6 | ||
|
|
33fa1ec441 | ||
|
|
77d56265ad | ||
|
|
7cc159f583 | ||
|
|
1ec68f5696 | ||
|
|
0c1dab89da | ||
|
|
65888c4296 | ||
|
|
a6c41d8d3f | ||
|
|
7ec4835c61 | ||
|
|
ad016aba35 | ||
|
|
b345e2039f | ||
|
|
3149a3bf3c | ||
|
|
f48f7d9edd | ||
|
|
a9f115d202 | ||
|
|
3eb018f39f | ||
|
|
019dbc073c | ||
|
|
b29c2c1571 | ||
|
|
ce7afeb250 | ||
|
|
b21855c294 | ||
|
|
cdab9aa19e | ||
|
|
72a2ded8ee | ||
|
|
1dc18645b2 | ||
|
|
c84c479871 | ||
|
|
2b4f673df1 | ||
|
|
9d4df54b3e | ||
|
|
a8fa3e1ecd | ||
|
|
a6a8ac04ac | ||
|
|
8edec0f7f6 | ||
|
|
18ae2a5fd4 | ||
|
|
ce72098175 | ||
|
|
a40d7b0676 | ||
|
|
14b3fbd496 | ||
|
|
926c3f62a0 | ||
|
|
c845a4fc4f | ||
|
|
25d8ad1532 | ||
|
|
0169eb3ba7 | ||
|
|
7065015462 | ||
|
|
b4366325cd | ||
|
|
61d09a9482 | ||
|
|
d2c5c923a3 | ||
|
|
d3f0704eae | ||
|
|
65d44394a6 | ||
|
|
cde0b49e2e | ||
|
|
dada8c83b5 | ||
|
|
c177c6e369 | ||
|
|
9d4eec350c | ||
|
|
0bb62dd3fb | ||
|
|
5ba5c26fcd | ||
|
|
606f9af1ba | ||
|
|
bcafbcc48c | ||
|
|
f77c228a02 | ||
|
|
19eaf1a4e3 | ||
|
|
82c13006ff | ||
|
|
f8cca5522f | ||
|
|
e544c0a801 | ||
|
|
8ed28a79b1 | ||
|
|
485ca82e30 | ||
|
|
9b9ecf76d6 | ||
|
|
7139211fa1 | ||
|
|
6202acf8fa | ||
|
|
fe9b55dcfd | ||
|
|
c11fc56209 | ||
|
|
850d92de28 | ||
|
|
6614a615c4 | ||
|
|
f28e920e15 | ||
|
|
1e2385da3b | ||
|
|
0e550d2511 | ||
|
|
9615a7e778 | ||
|
|
71c9fc8485 | ||
|
|
bc3396fed2 | ||
|
|
ce87cd88a0 | ||
|
|
9dad07344d | ||
|
|
b0b9a391a4 | ||
|
|
0eb0be4780 | ||
|
|
158d875123 | ||
|
|
579385ce3d | ||
|
|
1b7ec56c89 | ||
|
|
e556b83f5c | ||
|
|
e4f250907f | ||
|
|
962359dcf5 | ||
|
|
dab47e9a69 | ||
|
|
2ae223b552 | ||
|
|
6b22380d26 | ||
|
|
a3f02df539 | ||
|
|
222527ec7c | ||
|
|
934c56a20d | ||
|
|
38bc425d50 | ||
|
|
24348d4f01 | ||
|
|
f5ea1ce69e | ||
|
|
98bc077bb5 | ||
|
|
65bb7abf57 | ||
|
|
b842d1eabf | ||
|
|
63c9713ea8 | ||
|
|
25233344e7 | ||
|
|
ca22d405bf | ||
|
|
cb19d8256b | ||
|
|
4e529bc5e5 | ||
|
|
ef05b32d32 | ||
|
|
3bf4ecbe0f | ||
|
|
39d8282d7b | ||
|
|
248b4ec93c | ||
|
|
d31cda7448 | ||
|
|
ad6b8890c5 | ||
|
|
f33c5260df | ||
|
|
215502a0f0 | ||
|
|
def13e4f34 | ||
|
|
e089aa9663 | ||
|
|
4fedc8bece | ||
|
|
0d4e8c5c21 | ||
|
|
92041b73f1 | ||
|
|
402bc91566 | ||
|
|
1274942f64 | ||
|
|
36c9796ea1 | ||
|
|
d93b3e865b | ||
|
|
0df5fb7e3e | ||
|
|
dceebd1650 | ||
|
|
c5009f659e | ||
|
|
cd4236b310 | ||
|
|
1dd3b7d301 | ||
|
|
982f0c7d21 | ||
|
|
897a343936 | ||
|
|
5fe2dc619a | ||
|
|
1a0eacb077 | ||
|
|
09c05cc62a | ||
|
|
6090c01965 | ||
|
|
fd342e1cad | ||
|
|
025b61fb6e | ||
|
|
6f22fdb6ca | ||
|
|
325d3ca5c3 | ||
|
|
80a2f38272 | ||
|
|
936b15e5c2 | ||
|
|
1674671d7f | ||
|
|
5c2a016d4b | ||
|
|
a847c4dc56 | ||
|
|
666e417a13 | ||
|
|
cb46e7f3d5 | ||
|
|
dbee10361b | ||
|
|
56729e09c3 | ||
|
|
bd3dc894c4 | ||
|
|
28dad7373f | ||
|
|
b17846f264 | ||
|
|
057de9595b | ||
|
|
21663711f8 | ||
|
|
f23493742b | ||
|
|
f7051ed46b | ||
|
|
978707e434 | ||
|
|
9fbbb4896c | ||
|
|
72d43cceec | ||
|
|
f5ce41dc54 | ||
|
|
b038728013 | ||
|
|
ff9066ed1c | ||
|
|
3ae5a74395 | ||
|
|
de2b5a05bd | ||
|
|
d131026af9 | ||
|
|
9c554da2da | ||
|
|
9bba055e92 | ||
|
|
2d1e4507f4 | ||
|
|
1f911c3a75 | ||
|
|
fb5434da91 | ||
|
|
9c71db405a | ||
|
|
95061ec443 | ||
|
|
c9723de863 | ||
|
|
0444c887f4 | ||
|
|
6eb3bccd38 | ||
|
|
3395bad78b | ||
|
|
4adae16b80 | ||
|
|
687eca821d | ||
|
|
bdeee4ec31 | ||
|
|
98c6555ba5 | ||
|
|
8ae3b8d6c8 | ||
|
|
6357fd5b44 | ||
|
|
520649e862 | ||
|
|
80a9b8a34d | ||
|
|
7174556e73 | ||
|
|
3645f1aac8 | ||
|
|
9337b8adc7 | ||
|
|
5211556c7b | ||
|
|
83c95782bd | ||
|
|
b73b3512f3 | ||
|
|
9267458bca | ||
|
|
c110b303d7 | ||
|
|
ac007acbd7 | ||
|
|
5ef08b933e | ||
|
|
1ff965969d | ||
|
|
766f890b93 | ||
|
|
75a760fadb | ||
|
|
c4643b2d8b | ||
|
|
ec11d53fac | ||
|
|
2439150c6e | ||
|
|
fcb31d8052 | ||
|
|
15608f7cf2 | ||
|
|
913d74a0fb | ||
|
|
de9ed04ca8 | ||
|
|
2926be0d87 | ||
|
|
32b9f5b134 | ||
|
|
287564ee09 | ||
|
|
ea99c445f6 | ||
|
|
206cbf7e19 | ||
|
|
f785d0d8a2 | ||
|
|
6d7ecfe2fd | ||
|
|
7606c62b63 | ||
|
|
8ba9d872e2 | ||
|
|
8655fd1840 | ||
|
|
1a58e54ed0 | ||
|
|
6571468150 | ||
|
|
63869ef3b7 | ||
|
|
5f165308c1 | ||
|
|
60eaa82955 | ||
|
|
4cc16d5e58 | ||
|
|
95f4b207d5 | ||
|
|
a6baca3009 | ||
|
|
7afd493619 | ||
|
|
ace4e94071 | ||
|
|
f2bb65acf0 | ||
|
|
647f9062f8 | ||
|
|
fc138609a1 | ||
|
|
71994be407 | ||
|
|
7562ae2c77 | ||
|
|
ab51f768f9 | ||
|
|
be3c0023fe | ||
|
|
2855754648 | ||
|
|
47e7a18f2e | ||
|
|
4bfc9a1fff | ||
| 24f0b32611 | |||
|
|
336c6e993a | ||
|
|
584777328c | ||
|
|
61bf2a0450 | ||
|
|
bddc7bde60 | ||
|
|
b8bdd4ac5b | ||
|
|
45e20329a3 | ||
|
|
3c657dfa8c | ||
|
|
d3578184fb | ||
|
|
d189d51e26 | ||
|
|
a1285fe44d | ||
|
|
1ee3718cab | ||
|
|
0153465e29 | ||
|
|
88567c2c13 | ||
|
|
4f8d6a0a11 | ||
|
|
b995e2150b | ||
|
|
d1ac257ff9 | ||
|
|
b3b7176bcd | ||
|
|
9589095dc5 | ||
|
|
2ee9037322 | ||
|
|
a125e37f8e | ||
|
|
cbac9c5464 | ||
|
|
13527a3ca7 | ||
|
|
905ef99e0e | ||
|
|
9759e3e750 | ||
|
|
489ce67343 | ||
|
|
8b1f11d2ab | ||
|
|
8c9416a6c8 | ||
|
|
1fb2c82bd5 | ||
|
|
35df916a19 | ||
|
|
3878b84f4c | ||
|
|
3dd0ecd970 | ||
|
|
cdd35b447c | ||
|
|
7da4127652 | ||
|
|
8b1c2ebe3f | ||
|
|
f41536a793 | ||
|
|
d90f6f8261 | ||
|
|
e056f61b4a | ||
|
|
7e02081c55 | ||
|
|
1fc3632d6e | ||
|
|
ac96022178 | ||
|
|
bd33366342 | ||
|
|
9aa63fd11c | ||
|
|
c04b84a5b6 | ||
|
|
68d0c85dd2 | ||
|
|
a33a54b1de | ||
|
|
9ae604518a | ||
|
|
267065ef29 | ||
|
|
6b88da3f03 | ||
|
|
d6a5a82cf8 | ||
|
|
c53429fa6c | ||
|
|
8fa788e88c | ||
|
|
ad3df9b037 | ||
|
|
a9caf9d5ad | ||
|
|
881d603168 | ||
|
|
4b9ead8319 | ||
|
|
f3f72e2f28 | ||
|
|
71d4420604 | ||
|
|
481d6a1447 | ||
|
|
f8735348b5 | ||
|
|
5647eeb1a8 | ||
|
|
4e40662f22 | ||
|
|
9bdee807ee | ||
|
|
a7c4d1e450 | ||
|
|
2d237866fb | ||
|
|
6350ddc224 | ||
|
|
06adcdb2a8 | ||
|
|
6df352a2ab | ||
|
|
32ec3af729 | ||
|
|
cb2ad30b1d | ||
|
|
352eecc416 | ||
|
|
46bf0ecc0a | ||
|
|
9c3d9a5432 | ||
|
|
71cf05c820 | ||
|
|
836005aaff | ||
|
|
7cf475e057 | ||
|
|
4a946e4ab0 | ||
|
|
c6a5157fe7 | ||
|
|
9969ab2414 | ||
|
|
e78e2c2078 | ||
|
|
96ed0757de | ||
|
|
fdc5757c63 | ||
|
|
b8b9c976f6 | ||
|
|
081b9b43a8 | ||
|
|
f94df67ad8 | ||
|
|
f2c87b8d5f | ||
|
|
da78aa63ef | ||
|
|
3950cbd9e6 | ||
|
|
0f16ed8d46 | ||
|
|
3b3a3f41c3 | ||
|
|
2f4f4fffcf | ||
|
|
83052a2ba8 | ||
|
|
9dd38ac439 | ||
|
|
21f09426cf | ||
|
|
5afbdd0aa4 | ||
|
|
b356115c9b | ||
|
|
f9cbcda7e8 | ||
|
|
ba4f6b382c | ||
|
|
2e1db46e4a | ||
|
|
3f8a9e8efa | ||
|
|
5714fae7bd | ||
|
|
3cd86bb1da | ||
|
|
63936589d8 | ||
|
|
5107ce4b77 | ||
|
|
629ad3f7cd | ||
|
|
811388dd2e | ||
|
|
ed4800de6e | ||
|
|
e69395dca0 | ||
|
|
663885712b | ||
|
|
0a40f2d916 | ||
|
|
71b6418dfa | ||
|
|
63e9d5a95e | ||
|
|
5a28233856 | ||
|
|
830362b941 | ||
|
|
4ea5ea1705 | ||
|
|
de09942124 | ||
|
|
bcd149b304 | ||
|
|
20c712c56b | ||
|
|
c697e8629d | ||
|
|
cd2c48dd5c | ||
|
|
f480c9e080 | ||
|
|
618cc5e826 | ||
|
|
1b8adce5fb | ||
|
|
243cb347d1 | ||
|
|
5baa0b0ce3 | ||
|
|
4286551b12 | ||
|
|
ea821fd708 | ||
|
|
b35e0cf850 | ||
|
|
636ceacdad | ||
|
|
e2d2c27149 | ||
|
|
c511f5cb5a | ||
|
|
6d5d4d36c1 | ||
|
|
78725c1d14 | ||
|
|
28ec943a52 | ||
|
|
e91b2648cc | ||
|
|
74e04e1c7d | ||
|
|
53e9aea82f | ||
|
|
a710e40747 | ||
|
|
03deb9d375 | ||
|
|
069ac34b86 | ||
|
|
8db655cc37 | ||
|
|
9a5008080a | ||
|
|
67659a302b | ||
|
|
2193c92f16 | ||
|
|
4cbd35ce7a | ||
|
|
2a842ff246 | ||
|
|
3517476984 | ||
|
|
f316ef1946 | ||
|
|
5d1427db31 | ||
|
|
ece003ca4f | ||
|
|
9c2302bbfc | ||
|
|
2596ba9b47 | ||
|
|
76720fc72e | ||
|
|
3441cdc021 | ||
|
|
0b5f961087 | ||
|
|
fa0ab9f385 | ||
|
|
14c05f5ce9 | ||
|
|
0e24c3b820 | ||
|
|
8c5cc1041b | ||
|
|
c729c20771 | ||
|
|
98bf906916 | ||
|
|
9da5e6663e | ||
|
|
e53d74edd1 | ||
|
|
b470ef4857 | ||
|
|
af07240df0 | ||
|
|
18ce08ce07 | ||
|
|
2d2153448d | ||
|
|
061f5c942b | ||
|
|
6f0dd7b336 | ||
|
|
4df8f10ea9 | ||
|
|
29692bc9aa | ||
|
|
0be08c2312 | ||
|
|
656a9a7b15 | ||
|
|
00bb3c712b | ||
|
|
2489e8098d | ||
|
|
f8572d0795 | ||
|
|
78f2011d23 | ||
|
|
c12a67b113 | ||
|
|
338b1f8a4c | ||
|
|
d3093a1c4e | ||
|
|
e89cfc03e9 | ||
|
|
04426040d5 | ||
|
|
5d41e31d07 | ||
|
|
5df33837ab | ||
|
|
544d65972a | ||
|
|
6cc3dd32a5 | ||
|
|
5619905ae0 | ||
|
|
e08172f4a7 | ||
|
|
d138f78837 | ||
|
|
30343254bd | ||
|
|
6c2cd0ff42 | ||
|
|
cb32439896 | ||
|
|
3d2f19427a | ||
|
|
2298254903 | ||
|
|
2121ca7556 | ||
|
|
a834e76522 | ||
|
|
df9066eae0 | ||
|
|
e2e115ebeb | ||
|
|
e6016242ef | ||
|
|
8203e0eb50 | ||
|
|
f66ca6346f | ||
|
|
4e80d59c1c | ||
|
|
7794ae2564 | ||
|
|
7630e81a08 | ||
|
|
195954ed59 | ||
|
|
efb53bb906 | ||
|
|
ef2f8b357d | ||
|
|
697b8e4663 | ||
|
|
a41ff092cd | ||
|
|
f25b742877 | ||
|
|
d7dc098995 | ||
|
|
7288f9c58e | ||
|
|
b4f972dcbb | ||
|
|
34a680ae24 | ||
|
|
5f2f63696b | ||
|
|
c09c002471 | ||
|
|
d81dc3fe4e | ||
|
|
f7dccde746 | ||
|
|
0d42f8687f | ||
|
|
717494b4ad | ||
|
|
03096a80d2 | ||
|
|
963051e70d | ||
|
|
5913cc3b88 | ||
|
|
00866fd73a | ||
|
|
2b82ae78b7 | ||
|
|
1b41af5a2f | ||
|
|
906e1eb910 | ||
|
|
4d70009ed9 | ||
|
|
b59391f277 | ||
|
|
ec1692c066 | ||
|
|
30a32ca94f | ||
|
|
74986b1c6e | ||
|
|
6bfae35f6a | ||
|
|
c777a1564e | ||
|
|
d2ceb16a58 | ||
|
|
19fc4864cb | ||
|
|
c17d1b8ab5 | ||
|
|
587038d51a | ||
|
|
1bd8ee03b1 | ||
|
|
4785efd43c | ||
|
|
94e2a446e8 | ||
|
|
9edd61db78 | ||
|
|
061e38a78f | ||
|
|
a431f31a88 | ||
|
|
34c8c06ce9 | ||
|
|
25b465e62a | ||
|
|
bf7bb286af | ||
|
|
8beb2430b2 | ||
|
|
a319204910 | ||
|
|
01462da65f | ||
|
|
ffdc6fd0c8 | ||
|
|
7552499a76 | ||
|
|
06f9ea984f | ||
|
|
78725d1e88 | ||
|
|
3d06421acb | ||
|
|
0136515540 | ||
|
|
6ee9cc1fd2 | ||
|
|
ade0906534 | ||
|
|
98d1ccd614 | ||
|
|
6c91ba9eff | ||
|
|
ef0dc9e923 | ||
|
|
44a9b6ee0e | ||
|
|
2e4602e973 | ||
|
|
5c39413420 | ||
|
|
e3c8dc838d | ||
|
|
8582f28315 | ||
|
|
e9eb897f36 | ||
|
|
c85892d6cd | ||
|
|
8d23c49908 | ||
|
|
d08e87ec10 | ||
|
|
4b09276943 | ||
|
|
fa5e198b8a | ||
|
|
e1fbda5efc | ||
|
|
12f5d41811 | ||
|
|
81b6462b63 | ||
|
|
ee6c5c6f03 | ||
|
|
304bbe4f01 | ||
|
|
a6c2decfe3 | ||
|
|
46b9f5625b | ||
|
|
41527b2813 | ||
|
|
4c7ae4c8a8 | ||
|
|
145ae05354 | ||
|
|
4bfdd4f334 | ||
|
|
533e00d4ee | ||
|
|
a537eaad7b | ||
|
|
7b882c72cb | ||
|
|
aa24ad83e5 | ||
|
|
ee6780dc69 | ||
|
|
23f6032808 | ||
|
|
bf6b96fd91 | ||
|
|
7a63f475da | ||
|
|
55862da1d3 | ||
|
|
04c84db0ae | ||
|
|
a01c2e71a7 | ||
|
|
86880af581 | ||
|
|
60fca48a72 | ||
|
|
53c8483a3f | ||
|
|
9e186ab974 | ||
|
|
1fc00ed33f | ||
|
|
f8757c6d25 | ||
|
|
3a98629617 | ||
|
|
3ffad2ed49 | ||
|
|
a91eaf668b | ||
|
|
f5f34e31fa | ||
|
|
eb8fe17ae6 | ||
|
|
51913a5163 | ||
|
|
d3d5842804 | ||
|
|
b0b07365be | ||
|
|
5473c22c3f | ||
|
|
2c9a5b219b | ||
|
|
40b487994d | ||
|
|
4f92b8775d | ||
|
|
c0f48a73bb | ||
|
|
46d48a6d52 | ||
|
|
7876f26d0c | ||
|
|
085adeb096 | ||
|
|
f0fc9a7ccf | ||
|
|
319ae5a0ba | ||
|
|
c40609c8ac | ||
|
|
a393a5eb7a | ||
|
|
3c81a13acb | ||
|
|
1c3e54c895 | ||
|
|
8c141bbbe4 | ||
|
|
192fd144e8 | ||
|
|
015932a198 | ||
|
|
92fe2524e5 | ||
|
|
f7087517e4 | ||
|
|
c95444748a | ||
|
|
4a143d1814 | ||
|
|
68f4b1e909 | ||
|
|
977d6789da | ||
|
|
30da0319ce | ||
|
|
776f290ef9 | ||
|
|
ea5b50cf0a | ||
|
|
a10c9d8ffe | ||
|
|
74d7f9be29 | ||
|
|
2a4968568a | ||
|
|
206bee87d2 | ||
|
|
0569a1dd26 | ||
|
|
e9f8b2166e | ||
|
|
47949a81be | ||
|
|
0f5ebea399 | ||
|
|
378f05865d | ||
|
|
3c78f94a7e | ||
|
|
c5645f78e0 | ||
|
|
3be4a93e6f | ||
|
|
c368e2473e | ||
|
|
1a38fa7ca9 | ||
|
|
f697421c1b | ||
|
|
b4618e8cd1 | ||
|
|
bc0fa7f528 | ||
|
|
2e552c356c | ||
|
|
ccbce0e317 | ||
|
|
3bb34dae66 | ||
|
|
864d8c617f | ||
|
|
27ae980f42 | ||
|
|
cd00e5989b | ||
|
|
027aa4691d | ||
|
|
1e6e37f5e5 | ||
|
|
328477a1f3 | ||
|
|
2b9de01905 | ||
|
|
0c937d97cd | ||
|
|
69209957e4 | ||
|
|
ffd22e27bc | ||
|
|
c739d9d28c | ||
|
|
fab9ff88b5 | ||
|
|
e2454405ad | ||
|
|
84ef234d38 | ||
|
|
124239839c | ||
|
|
e0e28194f6 | ||
|
|
542c964436 | ||
|
|
bf86742976 | ||
|
|
b576afb971 | ||
|
|
7826364003 | ||
|
|
d24e208f0b | ||
|
|
df4eb10802 | ||
|
|
83239eb6f0 | ||
|
|
3ffd6214d8 | ||
|
|
125fb9a3e2 | ||
|
|
fe0190a674 | ||
|
|
e4b7722c50 | ||
|
|
552089b15c | ||
|
|
a726971324 | ||
|
|
fd4f077978 | ||
|
|
1042952598 | ||
|
|
c3dd91abba | ||
|
|
5c9f67c7e9 | ||
|
|
eb15544195 | ||
|
|
1c08bc067d | ||
|
|
f54ab84dd1 | ||
|
|
78caabeafc | ||
|
|
24a18e4d7a | ||
|
|
759cd73af4 | ||
|
|
9285ecca3d | ||
|
|
ddd4d998f4 | ||
|
|
9026fac581 | ||
|
|
b13742b165 | ||
|
|
4ad96eab66 | ||
|
|
5f7a99c7b5 | ||
|
|
3e04111cf4 | ||
|
|
b04e628e00 | ||
|
|
6c2d3e1a64 | ||
|
|
188c366e23 | ||
|
|
bda09badcf | ||
|
|
cd62f26a38 | ||
|
|
8fa419ef28 | ||
|
|
094ccd92da | ||
|
|
5e3cd04280 | ||
|
|
accf3319e7 | ||
|
|
71895e3a0f | ||
|
|
7b2c77d55e | ||
|
|
407a5a3f78 | ||
|
|
76da5b3af1 | ||
|
|
477f6eebdc | ||
|
|
7e5328f2b1 | ||
|
|
264e188e48 | ||
|
|
8f8f595940 | ||
|
|
94f35be93e | ||
|
|
55bd996970 | ||
|
|
08d055035b | ||
|
|
aa94f938c0 | ||
|
|
2c9c2c0de8 | ||
|
|
484b8bcb36 | ||
|
|
5e7c852624 | ||
|
|
36240edc12 | ||
|
|
186b23386a | ||
|
|
e81ab6d0bd | ||
|
|
a2dd56d475 | ||
|
|
966861f305 | ||
|
|
d79f5d0722 | ||
|
|
616c5bdd50 | ||
|
|
249593d547 | ||
|
|
407a6fb802 | ||
|
|
f823ef4db0 | ||
|
|
979d5e9ecc | ||
|
|
d4fa40900b | ||
|
|
3c23a7f9f8 | ||
|
|
c1d469b1f4 | ||
|
|
ecd7e6d7db | ||
|
|
2f9be97178 | ||
|
|
4994b26520 | ||
|
|
68fffa0af2 | ||
|
|
17cf67228f | ||
|
|
3cb24e1f5f | ||
|
|
dbe7db7160 | ||
|
|
212a4ac103 | ||
|
|
e6516d7acd | ||
|
|
e7cb7342aa | ||
|
|
0f7a3aa593 | ||
|
|
08f22073bd | ||
|
|
09f44fc1a0 | ||
|
|
fe7d3a8b32 | ||
|
|
e61fd2e172 | ||
|
|
f00ea38e17 | ||
|
|
c4665fdc04 | ||
|
|
359d2488d3 | ||
|
|
83ae874059 | ||
|
|
6c63da2a96 | ||
|
|
5fa50fecd3 | ||
|
|
978244dd22 | ||
|
|
41d46b1a13 | ||
|
|
c4b9b397a6 | ||
|
|
73b38d37e7 | ||
|
|
5e3fd93a23 | ||
|
|
d5f46690c4 | ||
|
|
6f91c200a9 | ||
|
|
22be4772db | ||
|
|
2cde70dff4 | ||
|
|
8f5c3ac4d6 | ||
|
|
b91f46723e | ||
|
|
80893ec033 | ||
|
|
6e53641d69 | ||
|
|
acd8348308 | ||
|
|
d322f595fe | ||
|
|
ab1fff53ef | ||
|
|
05cade1f99 | ||
|
|
9c0e30ec24 | ||
|
|
a555293e2f | ||
|
|
9a643a09ab | ||
|
|
05ca35feb1 | ||
|
|
ec19b9cbfe | ||
|
|
5d21172017 | ||
|
|
b28ebbe278 | ||
|
|
5b7a449938 | ||
|
|
aafa599b69 | ||
|
|
9af2121efe | ||
|
|
372e7a9976 | ||
|
|
6ffa180812 | ||
|
|
760f4bcccb | ||
|
|
d1b7f140fd | ||
|
|
af9b97f60c | ||
|
|
8561ef038e | ||
|
|
ccf8c21954 | ||
|
|
29f5ccc4bb | ||
|
|
6ef4e960d6 | ||
|
|
33c1a8c215 | ||
|
|
b292b3e3b5 | ||
|
|
97ccb3cb7c | ||
|
|
699f3a792d | ||
|
|
faa7298f89 | ||
|
|
4675f226ff | ||
|
|
6c049001f6 | ||
|
|
7c34042da3 | ||
|
|
a264b747e7 | ||
|
|
7daa566cec | ||
|
|
dd5e7e1acb | ||
|
|
8ef1afc4b9 | ||
|
|
2873b9bdd8 | ||
|
|
c26490f16e | ||
|
|
23bf7fd005 | ||
|
|
82537f192b | ||
|
|
acc47bad3d | ||
|
|
92b78d4573 | ||
|
|
fb5fe0e631 | ||
|
|
45d692fc4c | ||
|
|
a8127db4f6 | ||
|
|
1df975dfc6 | ||
|
|
ee21e19625 | ||
|
|
0f2f82b3ac | ||
|
|
b15ce2c153 | ||
|
|
86468e75cb | ||
|
|
c92545a22d | ||
|
|
e9ebf1bc00 | ||
|
|
d35199f693 | ||
|
|
1555f17747 | ||
|
|
dae923ee38 | ||
|
|
59f126e960 | ||
|
|
81218054a0 | ||
|
|
9bc5fcb642 | ||
|
|
9db979c31d | ||
|
|
4211f831bf | ||
|
|
c7c17c2e76 | ||
|
|
09b25d262c | ||
|
|
df4d5e52ae | ||
|
|
9c0fa6257a | ||
|
|
cbae26e492 | ||
|
|
34ff0cd314 | ||
|
|
fa455ba127 | ||
|
|
25bd4337c8 | ||
|
|
35b0350728 | ||
|
|
a85f636d24 | ||
|
|
08995d957c | ||
|
|
49933ee4a2 | ||
|
|
1644a92615 | ||
|
|
b2f111a4e7 | ||
|
|
ed2a21a51f | ||
|
|
76dd184c14 | ||
|
|
64a5573969 | ||
|
|
6543f29529 | ||
|
|
f1ceac09bb | ||
|
|
8fb32bd380 | ||
|
|
e2cdc27545 | ||
|
|
52daf39584 | ||
|
|
92a0f9071f | ||
|
|
4acc1203fe | ||
|
|
8e567dd401 | ||
|
|
0b6b864436 | ||
|
|
a6ec01d1e8 | ||
|
|
78629dd64f | ||
|
|
3f031da748 | ||
|
|
a3c6cf0c21 | ||
|
|
6182fc914e | ||
|
|
35a22bd2ba | ||
|
|
6bc2fde314 | ||
|
|
c62a82f673 | ||
|
|
d0350c6ad3 | ||
|
|
0f8a5403cd | ||
|
|
9de3d4ed5b | ||
|
|
7ed9d06b8d | ||
|
|
862aeebbf9 | ||
|
|
5bf220fa5b | ||
|
|
2aba89419e | ||
|
|
d1b8b981dd | ||
|
|
a7eb203b8c | ||
|
|
0522e3937f | ||
|
|
92e338ea1e | ||
|
|
be7712948a | ||
|
|
9f0f39f480 | ||
|
|
fe815bcce5 | ||
|
|
dacaa4a75d | ||
|
|
91977d6495 | ||
|
|
bfba681461 | ||
|
|
50658f6d91 | ||
|
|
3e609f7236 | ||
|
|
5ffbb8ed70 | ||
|
|
7bd5b915eb | ||
|
|
4343765aba | ||
|
|
4205e32d0f | ||
|
|
be7081359d | ||
|
|
14eee0240b | ||
|
|
72e1478e43 | ||
|
|
6a73adfd3d | ||
|
|
14fbf7a7be | ||
|
|
3429c83085 | ||
|
|
3946e603b3 | ||
|
|
8ec0d46d58 | ||
|
|
73e109c7c6 | ||
|
|
8a30987ad1 | ||
|
|
61b7743b77 | ||
|
|
109c4ef6f0 | ||
|
|
6b58d1673b | ||
|
|
35e5f18424 | ||
|
|
b5174df32c | ||
|
|
31f70c2ab1 | ||
|
|
234319ef50 | ||
|
|
33ac5e60b6 | ||
|
|
ee0c702135 | ||
|
|
e865baa795 | ||
|
|
00af3aab64 | ||
|
|
f2cfad50a9 | ||
|
|
c5b0c0543a | ||
|
|
869e164469 | ||
|
|
9561922e89 | ||
|
|
2b1e6fe8f5 | ||
|
|
bed8df06e8 | ||
|
|
414389c317 | ||
|
|
909dd43358 | ||
|
|
5966e06029 | ||
|
|
4a39941352 |
33
.dockerignore
Normal file
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Git
|
||||
.gitignore
|
||||
|
||||
#Docker files
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
@@ -1,9 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.gitignore
|
||||
@@ -1,14 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'@electron-toolkit/eslint-config-ts/recommended',
|
||||
'@electron-toolkit/eslint-config-prettier'
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
}
|
||||
}
|
||||
1
.github/FUNDING.yml
vendored
@@ -1 +0,0 @@
|
||||
github: [CodyTseng]
|
||||
57
.github/workflows/release.yml
vendored
@@ -1,57 +0,0 @@
|
||||
name: Build/release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*.*.*
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-13, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: build-linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: npm run build:linux
|
||||
|
||||
- name: build-mac
|
||||
if: matrix.os == 'macos-13'
|
||||
run: npm run build:mac
|
||||
|
||||
- name: build-win
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: npm run build:win
|
||||
|
||||
- name: release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
files: |
|
||||
dist/*.exe
|
||||
dist/*.zip
|
||||
dist/*.dmg
|
||||
dist/*.AppImage
|
||||
dist/*.snap
|
||||
dist/*.deb
|
||||
dist/*.rpm
|
||||
dist/*.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
27
.gitignore
vendored
@@ -1,5 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.log*
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.vercel
|
||||
|
||||
@@ -2,5 +2,4 @@ out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
*.json
|
||||
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
39
.vscode/launch.json
vendored
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Main Process",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
},
|
||||
"runtimeArgs": ["--sourcemap"],
|
||||
"env": {
|
||||
"REMOTE_DEBUGGING_PORT": "9222"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug Renderer Process",
|
||||
"port": 9222,
|
||||
"request": "attach",
|
||||
"type": "chrome",
|
||||
"webRoot": "${workspaceFolder}/src/renderer",
|
||||
"timeout": 60000,
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug All",
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.vscode/settings.json
vendored
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
410
AGENTS.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# AGENTS.md
|
||||
|
||||
This document is designed to help AI Agents better understand and modify the Smesh project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Smesh is a user-friendly Nostr client for exploring relay feeds.
|
||||
|
||||
- **Project Name**: Smesh
|
||||
- **Main Tech Stack**: React 18 + TypeScript + Vite
|
||||
- **UI Framework**: Tailwind CSS + Radix UI
|
||||
- **State Management**: Jotai
|
||||
- **Core Protocol**: Nostr (using nostr-tools)
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Core Dependencies
|
||||
|
||||
- **Build Tool**: Vite 5.x
|
||||
- **Frontend Framework**: React 18.3.x + TypeScript
|
||||
- **Styling Solution**:
|
||||
- Tailwind CSS (primary styling framework)
|
||||
- Radix UI (unstyled component library)
|
||||
- next-themes (theme management)
|
||||
- tailwindcss-animate (animations)
|
||||
- **State Management**: Jotai 2.x
|
||||
- **Routing**: path-to-regexp (custom routing solution)
|
||||
- **Rich Text Editor**: TipTap 2.x
|
||||
- **Nostr Protocol**: nostr-tools 2.x
|
||||
- **Other Key Libraries**:
|
||||
- i18next (internationalization)
|
||||
- dayjs (date handling)
|
||||
- flexsearch (search)
|
||||
- qr-code-styling (QR codes)
|
||||
- yet-another-react-lightbox (image viewer)
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
smesh/
|
||||
├── src/
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── ui/ # Base UI components (shadcn/ui style)
|
||||
│ │ └── ... # Other feature components
|
||||
│ ├── providers/ # React Context Providers
|
||||
│ ├── services/ # Business logic service layer
|
||||
│ ├── hooks/ # Custom React Hooks
|
||||
│ ├── lib/ # Utility functions and libraries
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ ├── pages/ # Page components
|
||||
| | ├── primary # Primary page components (Left column)
|
||||
│ │ └── secondary # secondary page components (Right column)
|
||||
│ ├── layouts/ # Layout components
|
||||
│ ├── i18n/ # Internationalization resources
|
||||
| | ├── locales # Localization files
|
||||
│ │ └── index.tx # Basic i18n setup
|
||||
│ ├── assets/ # Static assets
|
||||
│ ├── App.tsx # App root component
|
||||
│ ├── PageManager.tsx # Page manager (custom routing logic)
|
||||
│ ├── routes # Route configuration
|
||||
| | ├── primary.tsx # Primary routes (Left column)
|
||||
│ │ └── secondary.tsx # Secondary routes (Right column)
|
||||
│ └── constants.ts # Constants definition
|
||||
├── public/ # Public static assets
|
||||
└── resources/ # Design resources
|
||||
```
|
||||
|
||||
## Development Guide
|
||||
|
||||
### Environment Setup
|
||||
|
||||
### Environment Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Lint code
|
||||
npm run lint
|
||||
|
||||
# Format code
|
||||
npm run format
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Component Development
|
||||
|
||||
1. **Component Structure**: Each major feature component is typically in its own folder, containing index.tsx and related sub-components
|
||||
2. **Styling**: Use Tailwind CSS utility classes, complex components can use class-variance-authority (cva)
|
||||
3. **Type Safety**: All components should have explicit TypeScript type definitions
|
||||
4. **State Management**:
|
||||
- Use Jotai atoms for global state management
|
||||
- Use Context Providers for cross-component data
|
||||
|
||||
### Service Layer (Services)
|
||||
|
||||
Service files located in `src/services/` encapsulate business logic:
|
||||
|
||||
- `client.service.ts` - Nostr client core logic for fetching and publishing events
|
||||
- `indexed-db.service.ts` - IndexedDB data storage
|
||||
- `local-storage.service.ts` - LocalStorage management
|
||||
- `media-upload.service.ts` - Media upload service
|
||||
- `translation.service.ts` - Translation service
|
||||
- `lightning.service.ts` - Lightning Network integration
|
||||
- `relay-info.service.ts` - Relay information management
|
||||
- `blossom.service.ts` - Blossom integration
|
||||
- `custom-emoji.service.ts` - Custom emoji management
|
||||
- `libre-translate.service.ts` - LibreTranslate API integration
|
||||
- `media-manager.service.ts` - Managing media play state
|
||||
- `modal-manager.service.ts` - Managing modal stack for back navigation (ensures modals close one by one before actual page navigation)
|
||||
- `note-stats.service.ts` - Note statistics storage and retrieval (likes, zaps, reposts)
|
||||
- `poll-results.service.ts` - Poll results storage and retrieval
|
||||
- `post-editor-cache.service.ts` - Caching post editor content to prevent data loss
|
||||
- `web.push.service.ts` - Web metadata fetching for link previews
|
||||
|
||||
### Providers Architecture
|
||||
|
||||
The app uses a multi-layered Provider nesting structure (see `App.tsx`):
|
||||
|
||||
```
|
||||
ScreenSizeProvider
|
||||
└─ UserPreferencesProvider
|
||||
└─ ThemeProvider
|
||||
└─ ContentPolicyProvider
|
||||
└─ NostrProvider
|
||||
└─ ... (more providers)
|
||||
```
|
||||
|
||||
Pay attention to Provider dependencies when modifying functionality.
|
||||
|
||||
And some Providers are placed in `PageManager.tsx` because they need to use the `usePrimaryPage` and `useSecondaryPage` hooks.
|
||||
|
||||
### Routing System
|
||||
|
||||
- Route configuration in `src/routes/primary.tsx` and `src/routes/secondary.tsx`
|
||||
- Using `PageManager.tsx` to manage page navigation, rendering, and state. Normally, you don't need to modify this file.
|
||||
- Primary pages (left column) use key-based navigation
|
||||
- Secondary pages (right column) use path-based navigation with stack support
|
||||
- More details in "Adding a New Page" section below
|
||||
|
||||
### Internationalization (i18n)
|
||||
|
||||
Smesh is a multi-language application. When you add new text content, please ensure to add translations for all supported languages as much as possible. Append new translations to the end of each translation file without modifying or removing existing keys.
|
||||
|
||||
- Translation files located in `src/i18n/locales/`
|
||||
- Using `react-i18next` for internationalization
|
||||
- Supported languages: ar, de, en, es, fa, fr, hi, hu, it, ja, ko, pl, pt-BR, pt-PT, ru, th, zh, zh-TW
|
||||
|
||||
#### Adding New Language
|
||||
|
||||
1. Create a new file in `src/i18n/locales/` with the language code (e.g., `th.ts` for Thai)
|
||||
2. According to `src/i18n/locales/en.ts`, add translation key-value pairs
|
||||
3. Update `src/i18n/index.ts` to include the new language resource
|
||||
4. Update `detectLanguage` function in `src/lib/utils.ts` to support detecting the new language
|
||||
|
||||
## Nostr Protocol Integration
|
||||
|
||||
### Core Concepts
|
||||
|
||||
- **Events**: Nostr events (notes, profile updates, etc.). All data in Nostr is represented as events. They have different kinds (kinds) to represent different types of data.
|
||||
- **Relays**: Relay servers, which are WebSocket servers that store and forward Nostr events.
|
||||
- **NIPs**: Nostr Implementation Proposals
|
||||
|
||||
### Supported Event Kinds
|
||||
|
||||
I mean kinds that are supported to be displayed in the feed.
|
||||
|
||||
- Kind 1: Short Text Note
|
||||
- Kind 6: Repost
|
||||
- Kind 20: Picture Note
|
||||
- Kind 21: Video Note
|
||||
- Kind 22: Short Video Note
|
||||
- Kind 1068: Poll
|
||||
- Kind 1111: Comment
|
||||
- Kind 1222: Voice Note
|
||||
- Kind 1244: Voice Comment
|
||||
- Kind 9802: Highlight
|
||||
- Kind 30023: Long-Form Article
|
||||
- Kind 31987: Relay Review
|
||||
- Kind 34550: Community Definition
|
||||
- Kind 30311: Live Event
|
||||
- Kind 39000: Group Metadata
|
||||
- Kind 30030: Emoji Pack
|
||||
|
||||
More details you can find in `src/components/Note/`. If you want to add support for new kinds, you need to create new components under `src/components/Note/` and update `src/components/Note/index.tsx`.
|
||||
|
||||
And also you need to update `src/components/ContentPreview/` to support preview rendering for the new kinds. `ContentPreview` is used in various places like parent notes, notifications, highlight sources, etc. It only has one line of text space, so you need to figure out a suitable preview display method for different types of content. Use text only as much as possible.
|
||||
|
||||
Please avoid modifying the framework, such as avatars, usernames, timestamps, and action buttons in the `Note` component. Only add content rendering logic for new types.
|
||||
|
||||
## Common Components
|
||||
|
||||
### src/components/Note
|
||||
|
||||
Used to display a Nostr event (note).
|
||||
|
||||
Properties:
|
||||
|
||||
- `event`: `NoteEvent` - The Nostr event to display
|
||||
- `hideParentNotePreview`: `boolean` - Whether to hide the parent note preview
|
||||
- `showFull`: `boolean` - Whether to show the full content of the note. Default is `false`, which shows a truncated version with "Show more" option when content is long.
|
||||
|
||||
### src/components/NoteList
|
||||
|
||||
Used to display a list of notes with infinite scrolling support.
|
||||
|
||||
Properties:
|
||||
|
||||
- `subRequests`: `{ urls: string[]; filter: Omit<Filter, 'since' | 'until'> }[]` - Array of Nostr subscription requests to fetch notes
|
||||
- `urls`: Relay URLs for the subscription
|
||||
- `filter`: Nostr filter for the subscription (without `since`, `until` and `limit`, which are managed internally)
|
||||
- `showKinds`: `number[]` - Array of event kinds to display
|
||||
- `filterMutedNotes`: `boolean` - Whether to filter out muted notes
|
||||
- `hideReplies`: `boolean` - Whether to hide reply notes
|
||||
- `hideUntrustedNotes`: `boolean` - Whether to hide notes from untrusted authors
|
||||
- `filterFn`: `(note: NoteEvent) => boolean` - Custom filter function for notes. Return `true` to display the note, `false` to hide it.
|
||||
|
||||
### src/components/Tabs
|
||||
|
||||
A tab component for switching between different views.
|
||||
|
||||
Properties:
|
||||
|
||||
- `tabs`: `{ value: string; label: string }[]` - Array of tab definitions. `value` is the unique identifier for the tab, `label` is the display text. `label` will be passed through `t()` for translation.
|
||||
- `value`: `string` - Currently selected tab value.
|
||||
- `onChange`: `(value: string) => void` - Callback function when the selected tab changes.
|
||||
- `threshold`: `number` - Height threshold for hiding the tab bar on scroll down. Default is `800`. It should larger than the height of the area above the tab bar. Normally you don't need to change this value.
|
||||
- `options`: `React.ReactNode` - Additional options to display on the right side of the tab bar.
|
||||
|
||||
## Common Modification Scenarios
|
||||
|
||||
### Adding a New Component
|
||||
|
||||
1. Create a component folder in `src/components/`
|
||||
2. Create `index.tsx` and necessary sub-components
|
||||
3. Write styles using Tailwind CSS
|
||||
4. If needed, add base UI components in `src/components/ui/`
|
||||
|
||||
### Adding a New Page
|
||||
|
||||
#### Adding a Primary Page (Left Column)
|
||||
|
||||
Primary pages are the main navigation pages that appear in the left column (or full screen on mobile).
|
||||
|
||||
1. **Create the page component**:
|
||||
|
||||
```bash
|
||||
# Create a new folder under src/pages/primary/
|
||||
mkdir src/pages/primary/YourNewPage
|
||||
```
|
||||
|
||||
2. **Implement the component** (`src/pages/primary/YourNewPage/index.tsx`):
|
||||
|
||||
```tsx
|
||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||
import { TPageRef } from '@/types'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
const YourNewPage = forwardRef<TPageRef>((_, ref) => {
|
||||
return (
|
||||
<PrimaryPageLayout ref={ref} title="Your Page Title" icon={<YourIcon />}>
|
||||
{/* Your page content */}
|
||||
</PrimaryPageLayout>
|
||||
)
|
||||
})
|
||||
|
||||
export default YourNewPage
|
||||
```
|
||||
|
||||
**Important**:
|
||||
|
||||
- Primary pages MUST use `forwardRef<TPageRef>`
|
||||
- Wrap content with `PrimaryPageLayout`
|
||||
- The ref is used by PageManager for navigation control
|
||||
|
||||
3. **Register the route** in `src/routes/primary.tsx`:
|
||||
|
||||
```tsx
|
||||
import YourNewPage from '@/pages/primary/YourNewPage'
|
||||
|
||||
const PRIMARY_ROUTE_CONFIGS: RouteConfig[] = [
|
||||
// ... existing routes
|
||||
{ key: 'yourNewPage', component: YourNewPage }
|
||||
]
|
||||
```
|
||||
|
||||
4. **Navigate to the page** using the `usePrimaryPage` hook:
|
||||
|
||||
```tsx
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
|
||||
const { navigate } = usePrimaryPage()
|
||||
navigate('yourNewPage')
|
||||
```
|
||||
|
||||
#### Adding a Secondary Page (Right Column)
|
||||
|
||||
Secondary pages appear in the right column (or full screen on mobile) and support stack-based navigation.
|
||||
|
||||
1. **Create the page component**:
|
||||
|
||||
```bash
|
||||
# Create a new folder under src/pages/secondary/
|
||||
mkdir src/pages/secondary/YourNewPage
|
||||
```
|
||||
|
||||
2. **Implement the component** (`src/pages/secondary/YourNewPage/index.tsx`):
|
||||
|
||||
```tsx
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
const YourNewPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
return (
|
||||
<SecondaryPageLayout ref={ref} index={index} title="Your Page Title">
|
||||
{/* Your page content */}
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
})
|
||||
|
||||
export default YourNewPage
|
||||
```
|
||||
|
||||
**Important**:
|
||||
|
||||
- Secondary pages receive an `index` prop for stack navigation
|
||||
- Use `SecondaryPageLayout` for consistent styling
|
||||
- The ref enables navigation control
|
||||
|
||||
3. **Register the route** in `src/routes/secondary.tsx`:
|
||||
|
||||
```tsx
|
||||
import YourNewPage from '@/pages/secondary/YourNewPage'
|
||||
|
||||
const SECONDARY_ROUTE_CONFIGS = [
|
||||
// ... existing routes
|
||||
{ path: '/your-path/:id', element: <YourNewPage /> }
|
||||
]
|
||||
```
|
||||
|
||||
Add the corresponding path generation function in `src/lib/link.ts` for the new route:
|
||||
|
||||
```tsx
|
||||
export const toYourNewPage = (id: string) => `/your-path/${id}`
|
||||
```
|
||||
|
||||
4. **Navigate to the page**:
|
||||
|
||||
```tsx
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { toYourNewPage } from '@/lib/link'
|
||||
|
||||
const { push, pop } = useSecondaryPage()
|
||||
|
||||
// Navigate to new page
|
||||
push(toYourNewPage('some-id'))
|
||||
|
||||
// Navigate back
|
||||
pop()
|
||||
```
|
||||
|
||||
5. **Access route parameters**:
|
||||
|
||||
```tsx
|
||||
const YourNewPage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
|
||||
console.log('Route param id:', id)
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
#### Key Differences
|
||||
|
||||
| Aspect | Primary Pages | Secondary Pages |
|
||||
| -------------- | ----------------------------------- | ------------------------------- |
|
||||
| **Location** | Left column (main navigation) | Right column (detail view) |
|
||||
| **Navigation** | Replace-based (`navigate`) | Stack-based (`push`/`pop`) |
|
||||
| **Layout** | `PrimaryPageLayout` | `SecondaryPageLayout` |
|
||||
| **Routes** | Key-based (e.g., 'home', 'explore') | Path-based (e.g., '/notes/:id') |
|
||||
|
||||
On mobile devices or single-column layouts, primary pages occupy the full screen, while secondary pages are accessed via stack navigation. When navigating to another primary page, it will clear the secondary page stack.
|
||||
|
||||
### How to Parse and Render Content
|
||||
|
||||
First, use the `parseContent` method in `src/lib/content-parser.ts` to parse the content. It supports passing different parsers to parse only the needed content for different scenarios. You will get an array of `TEmbeddedNode[]`, and render the content according to the type of these nodes in order. If you need to support new node types, you can add new parsing methods in `src/lib/content-parser.ts`. If you want to recognize specific URLs as special types of nodes, you can extend the `EmbeddedUrlParser` method in `src/lib/content-parser.ts`. A complete usage example can be found in `src/components/Content/index.tsx`.
|
||||
|
||||
### Adding New State Management
|
||||
|
||||
1. For global state, create a new Provider in `src/providers/`
|
||||
2. Add Provider in `App.tsx` in the correct dependency order
|
||||
|
||||
Or create a singleton service in `src/services/` and use Jotai atoms for state management.
|
||||
|
||||
### Adding New Business Logic
|
||||
|
||||
1. Create a new service file in `src/services/`
|
||||
2. Export singleton instance
|
||||
3. Import and use in anywhere needed
|
||||
|
||||
### Style Modifications
|
||||
|
||||
- Global styles: `src/index.css`
|
||||
- Tailwind configuration: `tailwind.config.js`
|
||||
- Component styles: Use Tailwind class names directly
|
||||
786
DDD_ANALYSIS.md
Normal file
@@ -0,0 +1,786 @@
|
||||
# Domain-Driven Design Analysis: Smesh Nostr Client
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document provides a Domain-Driven Design (DDD) analysis of the Smesh codebase, a React/TypeScript Nostr client. The analysis identifies the implicit domain model, evaluates current architecture against DDD principles, and provides actionable recommendations for refactoring toward a more domain-centric design.
|
||||
|
||||
**Key Findings:**
|
||||
- The codebase has implicit bounded contexts but lacks explicit boundaries
|
||||
- Domain logic is scattered across providers, services, and lib utilities
|
||||
- The architecture exhibits several DDD anti-patterns (Anemic Domain Model, Smart UI tendencies)
|
||||
- Nostr events naturally align with Domain Events pattern
|
||||
- Strong foundation exists for incremental DDD adoption
|
||||
|
||||
---
|
||||
|
||||
## 1. Domain Analysis
|
||||
|
||||
### 1.1 Core Domain Identification
|
||||
|
||||
The Smesh application operates in the **decentralized social networking** domain, specifically implementing the Nostr protocol. The core business capabilities are:
|
||||
|
||||
| Subdomain | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| **Identity & Authentication** | Core | Key management, signing, account switching |
|
||||
| **Social Graph** | Core | Following, muting, trust relationships |
|
||||
| **Content Publishing** | Core | Notes, reactions, reposts, media |
|
||||
| **Feed Curation** | Core | Timeline construction, filtering, relay selection |
|
||||
| **Relay Management** | Supporting | Relay sets, discovery, connectivity |
|
||||
| **Notifications** | Supporting | Real-time event monitoring |
|
||||
| **Translation** | Generic | Multi-language content translation |
|
||||
| **Media Upload** | Generic | NIP-96/Blossom file hosting |
|
||||
|
||||
### 1.2 Ubiquitous Language
|
||||
|
||||
The codebase uses Nostr protocol terminology, which forms the basis of the ubiquitous language:
|
||||
|
||||
| Term | Definition | Current Implementation |
|
||||
|------|------------|----------------------|
|
||||
| **Event** | Signed JSON object (note, reaction, etc.) | `nostr-tools` Event type |
|
||||
| **Pubkey** | User's public key identifier | String (should be Value Object) |
|
||||
| **Relay** | WebSocket server for event distribution | String URL (should be Value Object) |
|
||||
| **Kind** | Event type identifier (0=profile, 1=note, etc.) | Number constants in `constants.ts` |
|
||||
| **Tag** | Metadata attached to events (p, e, a tags) | String arrays (should be Value Objects) |
|
||||
| **Profile** | User metadata (name, avatar, etc.) | `TProfile` type |
|
||||
| **Follow List** | User's contact list (kind 3) | Array in `FollowListProvider` |
|
||||
| **Mute List** | Blocked users/content (kind 10000) | Array in `MuteListProvider` |
|
||||
| **Relay List** | User's preferred relays (kind 10002) | `TRelayList` type |
|
||||
| **Signer** | Key management abstraction | `ISigner` interface |
|
||||
|
||||
**Language Issues Identified:**
|
||||
- "Stuff Stats" is unclear domain terminology (rename to `InteractionMetrics`)
|
||||
- "Favorite Relays" vs "Relay Sets" inconsistency
|
||||
- "Draft Event" conflates unsigned events with work-in-progress content
|
||||
|
||||
---
|
||||
|
||||
## 2. Current Architecture Assessment
|
||||
|
||||
### 2.1 Directory Structure Analysis
|
||||
|
||||
```
|
||||
src/
|
||||
├── providers/ # State management + some domain logic (17 contexts)
|
||||
├── services/ # Business logic + infrastructure concerns mixed
|
||||
├── lib/ # Utility functions + domain logic mixed
|
||||
├── types/ # Type definitions (implicit domain model)
|
||||
├── components/ # UI components (some contain business logic)
|
||||
├── pages/ # Page components
|
||||
└── hooks/ # Custom React hooks
|
||||
```
|
||||
|
||||
**Assessment:** The architecture follows a layered approach but lacks explicit domain layer separation. Domain logic is distributed across:
|
||||
- `lib/` - Event manipulation, validation
|
||||
- `services/` - Data fetching, caching, persistence
|
||||
- `providers/` - State management with embedded business rules
|
||||
|
||||
### 2.2 Implicit Bounded Contexts
|
||||
|
||||
The codebase contains several implicit bounded contexts that could be made explicit:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CONTEXT MAP │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ Partnership ┌──────────────┐ │
|
||||
│ │ Identity │◄────────────────────►│ Social Graph │ │
|
||||
│ │ Context │ │ Context │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ │ Customer/Supplier │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Content │ │ Feed │ │
|
||||
│ │ Context │ │ Context │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │
|
||||
│ └──────────────┬───────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Relay │ │
|
||||
│ │ Context │ │
|
||||
│ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Context Descriptions:**
|
||||
|
||||
1. **Identity Context**
|
||||
- Concerns: Key management, signing, account switching
|
||||
- Current: `NostrProvider`, `ISigner` implementations
|
||||
- Entities: Account, Signer
|
||||
|
||||
2. **Social Graph Context**
|
||||
- Concerns: Following, muting, trust, pinned users
|
||||
- Current: `FollowListProvider`, `MuteListProvider`, `UserTrustProvider`
|
||||
- Entities: User, FollowList, MuteList
|
||||
|
||||
3. **Content Context**
|
||||
- Concerns: Creating and publishing events
|
||||
- Current: `lib/draft-event.ts`, publishing logic in providers
|
||||
- Entities: Note, Reaction, Repost, Bookmark
|
||||
|
||||
4. **Feed Context**
|
||||
- Concerns: Timeline construction, filtering, display
|
||||
- Current: `FeedProvider`, `KindFilterProvider`, `NotificationProvider`
|
||||
- Entities: Feed, Filter, Timeline
|
||||
|
||||
5. **Relay Context**
|
||||
- Concerns: Relay management, connectivity, selection
|
||||
- Current: `FavoriteRelaysProvider`, `ClientService`
|
||||
- Entities: Relay, RelaySet, RelayList
|
||||
|
||||
---
|
||||
|
||||
## 3. Anti-Pattern Analysis
|
||||
|
||||
### 3.1 Anemic Domain Model
|
||||
|
||||
**Severity: High**
|
||||
|
||||
The current implementation exhibits a classic anemic domain model. Domain types are primarily data structures without behavior.
|
||||
|
||||
**Evidence:**
|
||||
|
||||
```typescript
|
||||
// Current: Types are data containers (src/types/index.d.ts)
|
||||
type TProfile = {
|
||||
pubkey: string
|
||||
username?: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
// ... no behavior
|
||||
}
|
||||
|
||||
// Business logic lives in external functions (src/lib/event-metadata.ts)
|
||||
export function extractProfileFromEventContent(event: Event): TProfile {
|
||||
// Logic external to the domain object
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:**
|
||||
- Business rules scattered across `lib/`, `services/`, `providers/`
|
||||
- Difficult to find all rules related to a concept
|
||||
- Easy to bypass validation by directly manipulating data
|
||||
|
||||
### 3.2 Smart UI Tendencies
|
||||
|
||||
**Severity: Medium**
|
||||
|
||||
Some business logic exists in UI components and providers that should be in domain layer.
|
||||
|
||||
**Evidence:**
|
||||
|
||||
```typescript
|
||||
// Provider contains domain logic (src/providers/FollowListProvider.tsx)
|
||||
const follow = async (pubkey: string) => {
|
||||
// Business rule: can't follow yourself
|
||||
if (pubkey === currentPubkey) return
|
||||
|
||||
// Business rule: avoid duplicates
|
||||
if (followList.includes(pubkey)) return
|
||||
|
||||
// Event creation and publishing
|
||||
const newFollowList = [...followList, pubkey]
|
||||
const draftEvent = createFollowListDraftEvent(...)
|
||||
await publish(draftEvent)
|
||||
}
|
||||
```
|
||||
|
||||
This logic belongs in a domain service or aggregate, not in a React context provider.
|
||||
|
||||
### 3.3 Database-Driven Design Elements
|
||||
|
||||
**Severity: Low**
|
||||
|
||||
The `IndexedDB` schema influences some type definitions, though this is less severe than traditional database-driven design.
|
||||
|
||||
**Evidence:**
|
||||
- Storage keys defined alongside domain constants
|
||||
- Some types mirror storage structure rather than domain concepts
|
||||
|
||||
### 3.4 Missing Aggregate Boundaries
|
||||
|
||||
**Severity: Medium**
|
||||
|
||||
No explicit aggregate roots or boundaries exist. Related data is managed independently.
|
||||
|
||||
**Evidence:**
|
||||
- `FollowList`, `MuteList`, `PinList` are managed by separate providers
|
||||
- No transactional consistency guarantees
|
||||
- Cross-cutting updates happen independently
|
||||
|
||||
### 3.5 Leaky Abstractions
|
||||
|
||||
**Severity: Medium**
|
||||
|
||||
Infrastructure concerns leak into what should be domain logic.
|
||||
|
||||
**Evidence:**
|
||||
|
||||
```typescript
|
||||
// Service mixes domain and infrastructure (src/services/client.service.ts)
|
||||
class ClientService extends EventTarget {
|
||||
private pool = new SimplePool() // Infrastructure
|
||||
private cache = new LRUCache(...) // Infrastructure
|
||||
private userIndex = new FlexSearch(...) // Infrastructure
|
||||
|
||||
// Domain logic mixed with caching, batching, retries
|
||||
async fetchProfile(pubkey: string): Promise<TProfile | null> {
|
||||
// Caching logic
|
||||
// Relay selection logic (domain)
|
||||
// Network calls (infrastructure)
|
||||
// Index updates (infrastructure)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Current Strengths
|
||||
|
||||
### 4.1 Natural Domain Event Alignment
|
||||
|
||||
Nostr events ARE domain events. The protocol's event-sourced nature aligns perfectly with DDD:
|
||||
|
||||
```typescript
|
||||
// Nostr events capture domain facts
|
||||
{
|
||||
kind: 1, // Note created
|
||||
content: "Hello Nostr!",
|
||||
tags: [["p", "..."]], // Mentions
|
||||
created_at: 1234567890,
|
||||
pubkey: "...",
|
||||
sig: "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Signer Interface Abstraction
|
||||
|
||||
The `ISigner` interface is a well-designed port in hexagonal architecture terms:
|
||||
|
||||
```typescript
|
||||
interface ISigner {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(draftEvent: TDraftEvent): Promise<VerifiedEvent>
|
||||
nip04Encrypt(pubkey: string, plainText: string): Promise<string>
|
||||
nip04Decrypt(pubkey: string, cipherText: string): Promise<string>
|
||||
}
|
||||
```
|
||||
|
||||
Multiple implementations exist: `NsecSigner`, `Nip07Signer`, `BunkerSigner`, etc.
|
||||
|
||||
### 4.3 Event Creation Factories
|
||||
|
||||
The `lib/draft-event.ts` file contains factory functions that encapsulate event creation:
|
||||
|
||||
```typescript
|
||||
createShortTextNoteDraftEvent(content, tags?, relays?)
|
||||
createReactionDraftEvent(event, emoji?)
|
||||
createFollowListDraftEvent(tags, content?)
|
||||
createBookmarkDraftEvent(tags, content?)
|
||||
```
|
||||
|
||||
These are proto-factories that could be formalized into proper Factory patterns.
|
||||
|
||||
### 4.4 Clear Type Definitions
|
||||
|
||||
The `types/index.d.ts` file provides a foundation for a rich domain model, even if currently anemic.
|
||||
|
||||
---
|
||||
|
||||
## 5. Refactoring Recommendations
|
||||
|
||||
### 5.1 Phase 1: Establish Domain Layer (Low Risk)
|
||||
|
||||
**Goal:** Create explicit domain layer without disrupting existing functionality.
|
||||
|
||||
**Actions:**
|
||||
|
||||
1. **Create domain directory structure:**
|
||||
|
||||
```
|
||||
src/
|
||||
├── domain/
|
||||
│ ├── identity/
|
||||
│ │ ├── Account.ts
|
||||
│ │ ├── Pubkey.ts (Value Object)
|
||||
│ │ └── index.ts
|
||||
│ ├── social/
|
||||
│ │ ├── FollowList.ts (Aggregate)
|
||||
│ │ ├── MuteList.ts (Aggregate)
|
||||
│ │ └── index.ts
|
||||
│ ├── content/
|
||||
│ │ ├── Note.ts (Entity)
|
||||
│ │ ├── Reaction.ts (Value Object)
|
||||
│ │ └── index.ts
|
||||
│ ├── relay/
|
||||
│ │ ├── Relay.ts (Value Object)
|
||||
│ │ ├── RelaySet.ts (Aggregate)
|
||||
│ │ └── index.ts
|
||||
│ └── shared/
|
||||
│ ├── EventId.ts
|
||||
│ ├── Timestamp.ts
|
||||
│ └── index.ts
|
||||
```
|
||||
|
||||
2. **Introduce Value Objects for primitives:**
|
||||
|
||||
```typescript
|
||||
// src/domain/identity/Pubkey.ts
|
||||
export class Pubkey {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
static fromHex(hex: string): Pubkey {
|
||||
if (!/^[0-9a-f]{64}$/i.test(hex)) {
|
||||
throw new InvalidPubkeyError(hex)
|
||||
}
|
||||
return new Pubkey(hex)
|
||||
}
|
||||
|
||||
static fromNpub(npub: string): Pubkey {
|
||||
const decoded = nip19.decode(npub)
|
||||
if (decoded.type !== 'npub') {
|
||||
throw new InvalidPubkeyError(npub)
|
||||
}
|
||||
return new Pubkey(decoded.data)
|
||||
}
|
||||
|
||||
toHex(): string { return this.value }
|
||||
toNpub(): string { return nip19.npubEncode(this.value) }
|
||||
|
||||
equals(other: Pubkey): boolean {
|
||||
return this.value === other.value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/domain/relay/RelayUrl.ts
|
||||
export class RelayUrl {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
static create(url: string): RelayUrl {
|
||||
const normalized = normalizeRelayUrl(url)
|
||||
if (!isValidRelayUrl(normalized)) {
|
||||
throw new InvalidRelayUrlError(url)
|
||||
}
|
||||
return new RelayUrl(normalized)
|
||||
}
|
||||
|
||||
toString(): string { return this.value }
|
||||
|
||||
equals(other: RelayUrl): boolean {
|
||||
return this.value === other.value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Create rich domain entities:**
|
||||
|
||||
```typescript
|
||||
// src/domain/social/FollowList.ts
|
||||
export class FollowList {
|
||||
private constructor(
|
||||
private readonly _ownerPubkey: Pubkey,
|
||||
private _following: Set<string>,
|
||||
private _petnames: Map<string, string>
|
||||
) {}
|
||||
|
||||
static empty(owner: Pubkey): FollowList {
|
||||
return new FollowList(owner, new Set(), new Map())
|
||||
}
|
||||
|
||||
static fromEvent(event: Event): FollowList {
|
||||
// Reconstitute from Nostr event
|
||||
}
|
||||
|
||||
follow(pubkey: Pubkey): FollowListUpdated {
|
||||
if (pubkey.equals(this._ownerPubkey)) {
|
||||
throw new CannotFollowSelfError()
|
||||
}
|
||||
if (this._following.has(pubkey.toHex())) {
|
||||
return FollowListUpdated.noChange()
|
||||
}
|
||||
this._following.add(pubkey.toHex())
|
||||
return FollowListUpdated.added(pubkey)
|
||||
}
|
||||
|
||||
unfollow(pubkey: Pubkey): FollowListUpdated {
|
||||
if (!this._following.has(pubkey.toHex())) {
|
||||
return FollowListUpdated.noChange()
|
||||
}
|
||||
this._following.delete(pubkey.toHex())
|
||||
return FollowListUpdated.removed(pubkey)
|
||||
}
|
||||
|
||||
isFollowing(pubkey: Pubkey): boolean {
|
||||
return this._following.has(pubkey.toHex())
|
||||
}
|
||||
|
||||
toDraftEvent(): TDraftEvent {
|
||||
// Convert to publishable event
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Phase 2: Introduce Domain Services (Medium Risk)
|
||||
|
||||
**Goal:** Extract business logic from providers into domain services.
|
||||
|
||||
**Actions:**
|
||||
|
||||
1. **Create domain services for cross-aggregate operations:**
|
||||
|
||||
```typescript
|
||||
// src/domain/content/PublishingService.ts
|
||||
export class PublishingService {
|
||||
constructor(
|
||||
private readonly relaySelector: RelaySelector,
|
||||
private readonly signer: ISigner
|
||||
) {}
|
||||
|
||||
async publishNote(
|
||||
content: string,
|
||||
mentions: Pubkey[],
|
||||
replyTo?: EventId
|
||||
): Promise<PublishedNote> {
|
||||
const note = Note.create(content, mentions, replyTo)
|
||||
const relays = await this.relaySelector.selectForPublishing(note)
|
||||
const signedEvent = await this.signer.signEvent(note.toDraftEvent())
|
||||
|
||||
return new PublishedNote(signedEvent, relays)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/domain/relay/RelaySelector.ts
|
||||
export class RelaySelector {
|
||||
constructor(
|
||||
private readonly userRelayList: RelayList,
|
||||
private readonly mentionRelayResolver: MentionRelayResolver
|
||||
) {}
|
||||
|
||||
async selectForPublishing(note: Note): Promise<RelayUrl[]> {
|
||||
const writeRelays = this.userRelayList.writeRelays()
|
||||
const mentionRelays = await this.resolveMentionRelays(note.mentions)
|
||||
|
||||
return this.mergeAndDeduplicate(writeRelays, mentionRelays)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Refactor providers to use domain services:**
|
||||
|
||||
```typescript
|
||||
// src/providers/ContentProvider.tsx (refactored)
|
||||
export function ContentProvider({ children }: Props) {
|
||||
const { signer, relayList } = useNostr()
|
||||
|
||||
// Domain service instantiation
|
||||
const publishingService = useMemo(
|
||||
() => new PublishingService(
|
||||
new RelaySelector(relayList, new MentionRelayResolver()),
|
||||
signer
|
||||
),
|
||||
[signer, relayList]
|
||||
)
|
||||
|
||||
const publishNote = useCallback(async (content: string, mentions: string[]) => {
|
||||
const pubkeys = mentions.map(Pubkey.fromHex)
|
||||
const result = await publishingService.publishNote(content, pubkeys)
|
||||
// Update UI state
|
||||
}, [publishingService])
|
||||
|
||||
return (
|
||||
<ContentContext.Provider value={{ publishNote }}>
|
||||
{children}
|
||||
</ContentContext.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Phase 3: Define Aggregate Boundaries (Higher Risk)
|
||||
|
||||
**Goal:** Establish clear aggregate roots with transactional boundaries.
|
||||
|
||||
**Proposed Aggregates:**
|
||||
|
||||
| Aggregate Root | Child Entities | Invariants |
|
||||
|----------------|----------------|------------|
|
||||
| `UserProfile` | Profile metadata | NIP-05 validation |
|
||||
| `FollowList` | Follow entries, petnames | No self-follow, unique entries |
|
||||
| `MuteList` | Public mutes, private mutes | Encryption for private |
|
||||
| `RelaySet` | Relay URLs, names | Valid URLs, unique within set |
|
||||
| `Bookmark` | Bookmarked events | Unique event references |
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
// src/domain/social/FollowList.ts (Aggregate Root)
|
||||
export class FollowList {
|
||||
private _domainEvents: DomainEvent[] = []
|
||||
|
||||
follow(pubkey: Pubkey): void {
|
||||
// Invariant enforcement
|
||||
this.ensureNotSelf(pubkey)
|
||||
this.ensureNotAlreadyFollowing(pubkey)
|
||||
|
||||
this._following.add(pubkey.toHex())
|
||||
|
||||
// Raise domain event
|
||||
this._domainEvents.push(
|
||||
new UserFollowed(this._ownerPubkey, pubkey, new Date())
|
||||
)
|
||||
}
|
||||
|
||||
pullDomainEvents(): DomainEvent[] {
|
||||
const events = [...this._domainEvents]
|
||||
this._domainEvents = []
|
||||
return events
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Phase 4: Introduce Repositories (Higher Risk)
|
||||
|
||||
**Goal:** Abstract persistence behind domain-focused interfaces.
|
||||
|
||||
```typescript
|
||||
// src/domain/social/FollowListRepository.ts (Interface in domain)
|
||||
export interface FollowListRepository {
|
||||
findByOwner(pubkey: Pubkey): Promise<FollowList | null>
|
||||
save(followList: FollowList): Promise<void>
|
||||
}
|
||||
|
||||
// src/infrastructure/persistence/IndexedDbFollowListRepository.ts
|
||||
export class IndexedDbFollowListRepository implements FollowListRepository {
|
||||
constructor(
|
||||
private readonly indexedDb: IndexedDbService,
|
||||
private readonly clientService: ClientService
|
||||
) {}
|
||||
|
||||
async findByOwner(pubkey: Pubkey): Promise<FollowList | null> {
|
||||
// Check IndexedDB cache
|
||||
const cached = await this.indexedDb.getFollowList(pubkey.toHex())
|
||||
if (cached) {
|
||||
return FollowList.fromEvent(cached)
|
||||
}
|
||||
|
||||
// Fetch from relays
|
||||
const event = await this.clientService.fetchFollowList(pubkey.toHex())
|
||||
if (event) {
|
||||
await this.indexedDb.saveFollowList(event)
|
||||
return FollowList.fromEvent(event)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async save(followList: FollowList): Promise<void> {
|
||||
const draftEvent = followList.toDraftEvent()
|
||||
// Sign and publish handled by application service
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 Phase 5: Event-Driven Architecture (Advanced)
|
||||
|
||||
**Goal:** Leverage Nostr's event-sourced nature for cross-context communication.
|
||||
|
||||
```typescript
|
||||
// src/domain/shared/DomainEvent.ts
|
||||
export abstract class DomainEvent {
|
||||
readonly occurredAt: Date = new Date()
|
||||
abstract get eventType(): string
|
||||
}
|
||||
|
||||
// src/domain/social/events/UserFollowed.ts
|
||||
export class UserFollowed extends DomainEvent {
|
||||
constructor(
|
||||
readonly follower: Pubkey,
|
||||
readonly followed: Pubkey,
|
||||
readonly timestamp: Date
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
get eventType(): string { return 'social.user_followed' }
|
||||
}
|
||||
|
||||
// src/application/handlers/UserFollowedHandler.ts
|
||||
export class UserFollowedHandler {
|
||||
constructor(
|
||||
private readonly notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
async handle(event: UserFollowed): Promise<void> {
|
||||
// Cross-context reaction
|
||||
await this.notificationService.notifyNewFollower(
|
||||
event.followed,
|
||||
event.follower
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Proposed Target Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── domain/ # Core domain logic (no dependencies)
|
||||
│ ├── identity/
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── Account.ts
|
||||
│ │ │ ├── Pubkey.ts
|
||||
│ │ │ └── Keypair.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ └── SigningService.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── social/
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── FollowList.ts
|
||||
│ │ │ ├── MuteList.ts
|
||||
│ │ │ └── UserProfile.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ └── TrustCalculator.ts
|
||||
│ │ ├── events/
|
||||
│ │ │ ├── UserFollowed.ts
|
||||
│ │ │ └── UserMuted.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── content/
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── Note.ts
|
||||
│ │ │ ├── Reaction.ts
|
||||
│ │ │ └── Repost.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ └── ContentValidator.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── relay/
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── RelayUrl.ts
|
||||
│ │ │ ├── RelaySet.ts
|
||||
│ │ │ └── RelayList.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ └── RelaySelector.ts
|
||||
│ │ └── index.ts
|
||||
│ └── shared/
|
||||
│ ├── EventId.ts
|
||||
│ ├── Timestamp.ts
|
||||
│ └── DomainEvent.ts
|
||||
│
|
||||
├── application/ # Use cases, orchestration
|
||||
│ ├── identity/
|
||||
│ │ └── AccountService.ts
|
||||
│ ├── social/
|
||||
│ │ ├── FollowService.ts
|
||||
│ │ └── MuteService.ts
|
||||
│ ├── content/
|
||||
│ │ └── PublishingService.ts
|
||||
│ └── handlers/
|
||||
│ └── DomainEventHandlers.ts
|
||||
│
|
||||
├── infrastructure/ # External concerns
|
||||
│ ├── persistence/
|
||||
│ │ ├── IndexedDbRepository.ts
|
||||
│ │ └── LocalStorageRepository.ts
|
||||
│ ├── nostr/
|
||||
│ │ ├── NostrClient.ts
|
||||
│ │ └── RelayPool.ts
|
||||
│ ├── signing/
|
||||
│ │ ├── NsecSigner.ts
|
||||
│ │ ├── Nip07Signer.ts
|
||||
│ │ └── BunkerSigner.ts
|
||||
│ └── translation/
|
||||
│ └── TranslationApiClient.ts
|
||||
│
|
||||
├── presentation/ # React components
|
||||
│ ├── providers/ # Thin wrappers around application services
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ └── hooks/
|
||||
│
|
||||
└── shared/ # Cross-cutting utilities
|
||||
├── lib/
|
||||
└── constants/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration Strategy
|
||||
|
||||
### 7.1 Incremental Approach
|
||||
|
||||
1. **Week 1-2:** Create `domain/shared/` with Value Objects (Pubkey, RelayUrl, EventId)
|
||||
2. **Week 3-4:** Migrate one bounded context (recommend: Social Graph)
|
||||
3. **Week 5-6:** Add domain services, refactor related providers
|
||||
4. **Week 7-8:** Introduce repositories for the migrated context
|
||||
5. **Ongoing:** Repeat for remaining contexts
|
||||
|
||||
### 7.2 Coexistence Strategy
|
||||
|
||||
During migration, old and new code can coexist:
|
||||
|
||||
```typescript
|
||||
// Adapter to bridge old and new
|
||||
export function legacyPubkeyToDomain(pubkey: string): Pubkey {
|
||||
return Pubkey.fromHex(pubkey)
|
||||
}
|
||||
|
||||
export function domainPubkeyToLegacy(pubkey: Pubkey): string {
|
||||
return pubkey.toHex()
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 Testing Strategy
|
||||
|
||||
- Unit test domain objects in isolation
|
||||
- Integration test application services
|
||||
- Keep existing component tests as regression safety
|
||||
|
||||
---
|
||||
|
||||
## 8. Metrics for Success
|
||||
|
||||
| Metric | Current State | Target State |
|
||||
|--------|---------------|--------------|
|
||||
| Domain logic in providers | ~60% | <10% |
|
||||
| Value Objects usage | 0 | 15+ types |
|
||||
| Explicit aggregates | 0 | 5 aggregates |
|
||||
| Domain events | 0 (implicit) | 10+ event types |
|
||||
| Repository interfaces | 0 | 5 repositories |
|
||||
| Test coverage (domain) | N/A | >80% |
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks and Mitigations
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|-------------|--------|------------|
|
||||
| Breaking changes during migration | Medium | High | Incremental migration, adapter layer |
|
||||
| Performance regression | Low | Medium | Benchmark critical paths, optimize lazily |
|
||||
| Team learning curve | Medium | Medium | Documentation, pair programming |
|
||||
| Over-engineering | Medium | Medium | YAGNI principle, concrete before abstract |
|
||||
|
||||
---
|
||||
|
||||
## 10. Conclusion
|
||||
|
||||
The Smesh codebase has a solid foundation that can be evolved toward DDD principles. The key recommendations are:
|
||||
|
||||
1. **Immediate:** Introduce Value Objects for Pubkey, RelayUrl, EventId
|
||||
2. **Short-term:** Create rich domain entities with behavior
|
||||
3. **Medium-term:** Extract domain services from providers
|
||||
4. **Long-term:** Full bounded context separation with repositories
|
||||
|
||||
The Nostr protocol's event-sourced nature is a natural fit for DDD, and the existing type definitions provide a starting point for a rich domain model. The main effort will be moving from an anemic model to entities with behavior, and establishing clear aggregate boundaries.
|
||||
|
||||
---
|
||||
|
||||
*Generated: December 2024*
|
||||
*Analysis based on DDD principles from Eric Evans and Vaughn Vernon*
|
||||
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# Step 1: Build the application
|
||||
FROM node:20-alpine as builder
|
||||
|
||||
ARG VITE_PROXY_SERVER
|
||||
ENV VITE_PROXY_SERVER=${VITE_PROXY_SERVER}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the source code to prevent invaliding cache whenever there is a change in the code
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Step 2: Final container with Nginx and embedded config
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy only the generated static files
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Embed Nginx configuration directly
|
||||
RUN printf "server {\n\
|
||||
listen 80;\n\
|
||||
server_name localhost;\n\
|
||||
root /usr/share/nginx/html;\n\
|
||||
index index.html;\n\
|
||||
\n\
|
||||
location / {\n\
|
||||
try_files \$uri \$uri/ /index.html;\n\
|
||||
}\n\
|
||||
\n\
|
||||
location ~* \\.(?:js|css|woff2?|ttf|otf|eot|ico|jpg|jpeg|png|gif|svg|webp)\$ {\n\
|
||||
expires 30d;\n\
|
||||
access_log off;\n\
|
||||
add_header Cache-Control \"public\";\n\
|
||||
}\n\
|
||||
\n\
|
||||
gzip on;\n\
|
||||
gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/json;\n\
|
||||
gzip_proxied any;\n\
|
||||
gzip_min_length 1024;\n\
|
||||
gzip_comp_level 6;\n\
|
||||
}\n" > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
4
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
MIT LICENSE
|
||||
|
||||
Copyright (c) 2024 Cody Tseng
|
||||
Copyright (c) 2025 Cody Tseng
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
72
README.md
@@ -1,62 +1,68 @@
|
||||
<div align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./resources/logo-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./resources/logo-light.svg">
|
||||
<img src="./resources/logo-light.svg" alt="Jumble Logo" width="400" />
|
||||
<img src="./resources/logo-light.svg" alt="Smesh Logo" width="400" />
|
||||
</picture>
|
||||
<p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p>
|
||||
</div>
|
||||
|
||||
# Jumble
|
||||
# Smesh
|
||||
|
||||
A beautiful nostr client focused on browsing relay feeds
|
||||
A user-friendly Nostr client for exploring relay feeds
|
||||
|
||||
## Features
|
||||
Experience Smesh at [https://smesh.social](https://smesh.social)
|
||||
|
||||
- **Relay-Based Browsing:** Explore content directly through relays without following specific users
|
||||
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
|
||||
- **Relay Groups:** Easily manage and switch between relay groups
|
||||
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions
|
||||
- **Cross-Platform:** Available on macOS, Windows, Linux, and web browsers
|
||||
## Forks
|
||||
|
||||
## Web Version
|
||||
> Some interesting forks of Smesh.
|
||||
|
||||
You can use the web version of Jumble at [jumble.social](https://jumble.social).
|
||||
- [https://fevela.me/](https://fevela.me/) - by [@daniele](https://smesh.social/users/npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk)
|
||||
- [https://x21.com/](https://x21.com/) - by [@Karnage](https://smesh.social/users/npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac)
|
||||
- [https://smesh.imwald.eu/](https://smesh.imwald.eu/) Repo: [Silberengel/smesh](https://github.com/Silberengel/smesh) - by [@Silberengel](https://smesh.social/users/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z)
|
||||
|
||||
## Desktop Version
|
||||
|
||||
You can download the desktop version from the [release page](https://github.com/CodyTseng/jumble/releases). If you want to use Apple Silicon version, you need to build it from the source code.
|
||||
|
||||
Because the app is not signed, you may need to allow it to run in the system settings.
|
||||
|
||||
## Build from source
|
||||
|
||||
You can also build the app from the source code.
|
||||
|
||||
> Note: Node.js >= 20 is required.
|
||||
## Run Locally
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
git clone https://github.com/CodyTseng/jumble.git
|
||||
git clone https://git.mleku.dev/mleku/smesh.git
|
||||
|
||||
# Go into the repository
|
||||
cd jumble
|
||||
cd smesh
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build the app
|
||||
npm run build:mac
|
||||
# or npm run build:win
|
||||
# or npm run build:linux
|
||||
# or npm run build:web
|
||||
# Run the app
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The executable file will be in the `dist` folder.
|
||||
## Run Docker
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
git clone https://git.mleku.dev/mleku/smesh.git
|
||||
|
||||
# Go into the repository
|
||||
cd smesh
|
||||
|
||||
# Run the docker compose
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
After finishing, access: http://localhost:8089
|
||||
|
||||
## Sponsors
|
||||
|
||||
<a target="_blank" href="https://opensats.org/">
|
||||
<img alt="open-sats-logo" src="./resources/open-sats-logo.svg" height="44">
|
||||
</a>
|
||||
|
||||
## Donate
|
||||
|
||||
If you like this project, you can buy me a coffee :) ⚡️ codytseng@getalby.com ⚡️
|
||||
If you like this project, you can buy me a coffee :)
|
||||
|
||||
- **Lightning:** ⚡️ codytseng@getalby.com ⚡️
|
||||
- **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht
|
||||
- **Geyser:** https://geyser.fund/project/smesh
|
||||
|
||||
## License
|
||||
|
||||
|
||||
BIN
build/icon.icns
BIN
build/icon.ico
|
Before Width: | Height: | Size: 13 KiB |
BIN
build/icon.png
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 71 KiB |
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/renderer/src/assets/main.css",
|
||||
"baseColor": "slate",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@renderer/components",
|
||||
"utils": "@renderer/lib/utils"
|
||||
}
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
||||
44
docker-compose.dev.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
smesh:
|
||||
container_name: smesh-nginx
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_PROXY_SERVER: ${SMESH_PROXY_SERVER_URL:-http://localhost:8090}
|
||||
ports:
|
||||
- '8089:80'
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- smesh
|
||||
|
||||
proxy-server:
|
||||
image: ghcr.io/danvergara/smesh-proxy-server:latest
|
||||
environment:
|
||||
- ALLOW_ORIGIN=${SMESH_SOCIAL_URL:-http://localhost:8089}
|
||||
- SMESH_PROXY_GITHUB_TOKEN=${SMESH_PROXY_GITHUB_TOKEN}
|
||||
- ENABLE_PPROF=true
|
||||
- PORT=8080
|
||||
ports:
|
||||
- '8090:8080'
|
||||
networks:
|
||||
- smesh
|
||||
|
||||
nostr-relay:
|
||||
image: scsibug/nostr-rs-relay:latest
|
||||
container_name: smesh-nostr-relay
|
||||
ports:
|
||||
- '7000:8080'
|
||||
environment:
|
||||
- RUST_LOG=warn,nostr_rs_relay=info
|
||||
volumes:
|
||||
- relay-data:/usr/src/app/db
|
||||
networks:
|
||||
- smesh
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
relay-data:
|
||||
|
||||
networks:
|
||||
smesh:
|
||||
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
smesh:
|
||||
container_name: smesh-nginx
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_PROXY_SERVER: ${SMESH_PROXY_SERVER_URL:-http://localhost:8090}
|
||||
ports:
|
||||
- '8089:80'
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- smesh
|
||||
|
||||
proxy-server:
|
||||
image: ghcr.io/danvergara/smesh-proxy-server:latest
|
||||
environment:
|
||||
- ALLOW_ORIGIN=${SMESH_SOCIAL_URL:-http://localhost:8089}
|
||||
- SMESH_PROXY_GITHUB_TOKEN=${SMESH_PROXY_GITHUB_TOKEN}
|
||||
- ENABLE_PPROF=true
|
||||
- PORT=8080
|
||||
ports:
|
||||
- '8090:8080'
|
||||
networks:
|
||||
- smesh
|
||||
|
||||
networks:
|
||||
smesh:
|
||||
@@ -1,34 +0,0 @@
|
||||
appId: com.jumble.app
|
||||
productName: jumble
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
executableName: jumble
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: codytseng
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
@@ -1,21 +0,0 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@common': resolve('src/common')
|
||||
}
|
||||
},
|
||||
plugins: [react()]
|
||||
}
|
||||
})
|
||||
30
eslint.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off'
|
||||
}
|
||||
}
|
||||
)
|
||||
59
index.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
|
||||
<title>Smesh</title>
|
||||
<meta name="description" content="A user-friendly Nostr client for exploring relay feeds" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="smesh, nostr, web, client, relay, feed, social, pwa, simple, clean"
|
||||
/>
|
||||
|
||||
<meta name="apple-mobile-web-app-title" content="Smesh" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
|
||||
|
||||
<meta property="og:url" content="https://smesh.mleku.dev" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Smesh" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A user-friendly Nostr client for exploring relay feeds"
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://git.mleku.dev/mleku/smesh/raw/branch/master/resources/og-image.png"
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Prevent flash - set background based on system preference before CSS loads */
|
||||
html, body, #root { background-color: #FFFFFF; color: #171717; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body, #root { background-color: #171717; color: #FAFAFA; }
|
||||
}
|
||||
html.light, html.light body, html.light #root { background-color: #FFFFFF !important; color: #171717 !important; }
|
||||
html.dark, html.dark body, html.dark #root { background-color: #171717 !important; color: #FAFAFA !important; }
|
||||
html.dark.pure-black, html.dark.pure-black body, html.dark.pure-black #root { background-color: #000000 !important; }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme-setting');
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.add('light');
|
||||
} else if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else if (theme === 'pure-black') {
|
||||
document.documentElement.classList.add('dark', 'pure-black');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
12976
package-lock.json
generated
167
package.json
@@ -1,90 +1,115 @@
|
||||
{
|
||||
"name": "jumble",
|
||||
"name": "smesh",
|
||||
"version": "0.1.0",
|
||||
"description": "Yet another Nostr desktop client",
|
||||
"main": "./out/main/index.js",
|
||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"author": "codytseng",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/CodyTseng/jumble"
|
||||
"url": "git+https://git.mleku.dev/mleku/smesh"
|
||||
},
|
||||
"homepage": "https://github.com/CodyTseng/jumble",
|
||||
"homepage": "https://git.mleku.dev/mleku/smesh",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"dev:8080": "vite --host 0.0.0.0 --port 8080",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"dev:web": "vite --config web.vite.config.ts",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win -p never",
|
||||
"build:mac": "electron-vite build && electron-builder --mac -p never",
|
||||
"build:linux": "electron-vite build && electron-builder --linux -p never",
|
||||
"build:web": "vite build --config web.vite.config.ts"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@nextui-org/image": "^2.0.32",
|
||||
"@nextui-org/system": "^2.2.6",
|
||||
"@nextui-org/theme": "^2.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@getalby/bitcoin-connect-react": "^3.10.0",
|
||||
"@noble/hashes": "^1.6.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-hover-card": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tiptap/extension-emoji": "^2.26.1",
|
||||
"@tiptap/extension-history": "^2.12.0",
|
||||
"@tiptap/extension-mention": "^2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/react": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^2.12.0",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"blossom-client-sdk": "^4.1.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"dataloader": "^2.2.2",
|
||||
"dataloader": "^2.2.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"framer-motion": "^11.11.17",
|
||||
"i18next": "^23.16.5",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"lru-cache": "^11.0.1",
|
||||
"lucide-react": "^0.453.0",
|
||||
"nostr-tools": "^2.9.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"embla-carousel-wheel-gestures": "^8.1.0",
|
||||
"emoji-picker-react": "^4.12.2",
|
||||
"flexsearch": "^0.7.43",
|
||||
"franc-min": "^6.2.0",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"jotai": "^2.15.0",
|
||||
"lru-cache": "^11.0.2",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nostr-tools": "^2.17.0",
|
||||
"nstart-modal": "^2.0.0",
|
||||
"path-to-regexp": "^8.2.0",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"react-i18next": "^15.1.1",
|
||||
"react-resizable-panels": "^2.1.5",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"yet-another-react-lightbox": "^3.21.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^2.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@types/node": "^20.14.8",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"electron": "^31.0.2",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.3",
|
||||
"prettier": "^3.3.2",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.5.2",
|
||||
"vite": "^5.3.1"
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thumbhash": "^0.1.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"uri-templates": "^0.2.0",
|
||||
"vaul": "^1.1.2",
|
||||
"yet-another-react-lightbox": "^3.21.7",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/uri-templates": "^0.1.34",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.13.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')]
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
|
||||
7
public/.well-known/nostr.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"names": {
|
||||
"_": "f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a",
|
||||
"cody": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883",
|
||||
"cody2": "24462930821b45f530ec0063eca0a6522e5a577856f982fa944df0ef3caf03ab"
|
||||
}
|
||||
}
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
9
public/pwa-monochrome.svg
Normal file
|
After Width: | Height: | Size: 47 KiB |
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
1
resources/icon-rounded.svg
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 91 KiB |
16
resources/open-sats-logo.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
69
src/App.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'yet-another-react-lightbox/styles.css'
|
||||
import './index.css'
|
||||
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { BookmarksProvider } from '@/providers/BookmarksProvider'
|
||||
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
|
||||
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
|
||||
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
|
||||
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
|
||||
import { FeedProvider } from '@/providers/FeedProvider'
|
||||
import { FollowListProvider } from '@/providers/FollowListProvider'
|
||||
import { KindFilterProvider } from '@/providers/KindFilterProvider'
|
||||
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
||||
import { MuteListProvider } from '@/providers/MuteListProvider'
|
||||
import { NostrProvider } from '@/providers/NostrProvider'
|
||||
import { PinListProvider } from '@/providers/PinListProvider'
|
||||
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
||||
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
|
||||
import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider'
|
||||
import { UserTrustProvider } from '@/providers/UserTrustProvider'
|
||||
import { ZapProvider } from '@/providers/ZapProvider'
|
||||
import { PageManager } from './PageManager'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
return (
|
||||
<ScreenSizeProvider>
|
||||
<UserPreferencesProvider>
|
||||
<ThemeProvider>
|
||||
<ContentPolicyProvider>
|
||||
<DeletedEventProvider>
|
||||
<NostrProvider>
|
||||
<ZapProvider>
|
||||
<TranslationServiceProvider>
|
||||
<FavoriteRelaysProvider>
|
||||
<FollowListProvider>
|
||||
<MuteListProvider>
|
||||
<UserTrustProvider>
|
||||
<BookmarksProvider>
|
||||
<EmojiPackProvider>
|
||||
<PinListProvider>
|
||||
<PinnedUsersProvider>
|
||||
<FeedProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
</MediaUploadServiceProvider>
|
||||
</FeedProvider>
|
||||
</PinnedUsersProvider>
|
||||
</PinListProvider>
|
||||
</EmojiPackProvider>
|
||||
</BookmarksProvider>
|
||||
</UserTrustProvider>
|
||||
</MuteListProvider>
|
||||
</FollowListProvider>
|
||||
</FavoriteRelaysProvider>
|
||||
</TranslationServiceProvider>
|
||||
</ZapProvider>
|
||||
</NostrProvider>
|
||||
</DeletedEventProvider>
|
||||
</ContentPolicyProvider>
|
||||
</ThemeProvider>
|
||||
</UserPreferencesProvider>
|
||||
</ScreenSizeProvider>
|
||||
)
|
||||
}
|
||||
534
src/PageManager.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
|
||||
import { TPageRef } from '@/types'
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
createRef,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import BackgroundAudio from './components/BackgroundAudio'
|
||||
import BottomNavigationBar from './components/BottomNavigationBar'
|
||||
import CreateWalletGuideToast from './components/CreateWalletGuideToast'
|
||||
import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
|
||||
import { normalizeUrl } from './lib/url'
|
||||
import { NotificationProvider } from './providers/NotificationProvider'
|
||||
import { useScreenSize } from './providers/ScreenSizeProvider'
|
||||
import { useTheme } from './providers/ThemeProvider'
|
||||
import { useUserPreferences } from './providers/UserPreferencesProvider'
|
||||
import { PRIMARY_PAGE_MAP, PRIMARY_PAGE_REF_MAP, TPrimaryPageName } from './routes/primary'
|
||||
import { SECONDARY_ROUTES } from './routes/secondary'
|
||||
import modalManager from './services/modal-manager.service'
|
||||
|
||||
type TPrimaryPageContext = {
|
||||
navigate: (page: TPrimaryPageName, props?: object) => void
|
||||
current: TPrimaryPageName | null
|
||||
display: boolean
|
||||
}
|
||||
|
||||
type TSecondaryPageContext = {
|
||||
push: (url: string) => void
|
||||
pop: () => void
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
type TStackItem = {
|
||||
index: number
|
||||
url: string
|
||||
element: React.ReactElement | null
|
||||
ref: RefObject<TPageRef> | null
|
||||
}
|
||||
|
||||
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
|
||||
|
||||
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
|
||||
|
||||
export function usePrimaryPage() {
|
||||
const context = useContext(PrimaryPageContext)
|
||||
if (!context) {
|
||||
throw new Error('usePrimaryPage must be used within a PrimaryPageContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function useSecondaryPage() {
|
||||
const context = useContext(SecondaryPageContext)
|
||||
if (!context) {
|
||||
throw new Error('usePrimaryPage must be used within a SecondaryPageContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
|
||||
const [primaryPages, setPrimaryPages] = useState<
|
||||
{ name: TPrimaryPageName; element: ReactNode; props?: any }[]
|
||||
>([
|
||||
{
|
||||
name: 'home',
|
||||
element: PRIMARY_PAGE_MAP.home
|
||||
}
|
||||
])
|
||||
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { themeSetting } = useTheme()
|
||||
const { enableSingleColumnLayout } = useUserPreferences()
|
||||
const ignorePopStateRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
navigatePrimaryPage('search')
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isSmallScreen])
|
||||
|
||||
useEffect(() => {
|
||||
if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/users' + window.location.pathname + window.location.search + window.location.hash
|
||||
)
|
||||
} else if (
|
||||
['/note1', '/nevent1', '/naddr1'].some((prefix) =>
|
||||
window.location.pathname.startsWith(prefix)
|
||||
)
|
||||
) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/notes' + window.location.pathname + window.location.search + window.location.hash
|
||||
)
|
||||
}
|
||||
window.history.pushState(null, '', window.location.href)
|
||||
if (window.location.pathname !== '/') {
|
||||
const url = window.location.pathname + window.location.search + window.location.hash
|
||||
setSecondaryStack((prevStack) => {
|
||||
if (isCurrentPage(prevStack, url)) return prevStack
|
||||
|
||||
const { newStack, newItem } = pushNewPageToStack(
|
||||
prevStack,
|
||||
url,
|
||||
maxStackSize,
|
||||
window.history.state?.index
|
||||
)
|
||||
if (newItem) {
|
||||
window.history.replaceState({ index: newItem.index, url }, '', url)
|
||||
}
|
||||
return newStack
|
||||
})
|
||||
} else {
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const r = searchParams.get('r')
|
||||
if (r) {
|
||||
const url = normalizeUrl(r)
|
||||
if (url) {
|
||||
navigatePrimaryPage('relay', { url })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPopState = (e: PopStateEvent) => {
|
||||
if (ignorePopStateRef.current) {
|
||||
ignorePopStateRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const closeModal = modalManager.pop()
|
||||
if (closeModal) {
|
||||
ignorePopStateRef.current = true
|
||||
window.history.forward()
|
||||
return
|
||||
}
|
||||
|
||||
let state = e.state as { index: number; url: string } | null
|
||||
setSecondaryStack((pre) => {
|
||||
const currentItem = pre[pre.length - 1] as TStackItem | undefined
|
||||
const currentIndex = currentItem?.index
|
||||
if (!state) {
|
||||
if (window.location.pathname + window.location.search + window.location.hash !== '/') {
|
||||
// Just change the URL
|
||||
return pre
|
||||
} else {
|
||||
// Back to root
|
||||
state = { index: -1, url: '/' }
|
||||
}
|
||||
}
|
||||
|
||||
// Go forward
|
||||
if (currentIndex === undefined || state.index > currentIndex) {
|
||||
const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
|
||||
return newStack
|
||||
}
|
||||
|
||||
if (state.index === currentIndex) {
|
||||
return pre
|
||||
}
|
||||
|
||||
// Go back
|
||||
const newStack = pre.filter((item) => item.index <= state!.index)
|
||||
const topItem = newStack[newStack.length - 1] as TStackItem | undefined
|
||||
if (!topItem) {
|
||||
// Create a new stack item if it's not exist (e.g. when the user refreshes the page, the stack will be empty)
|
||||
const { element, ref } = findAndCloneElement(state.url, state.index)
|
||||
if (element) {
|
||||
newStack.push({
|
||||
index: state.index,
|
||||
url: state.url,
|
||||
element,
|
||||
ref
|
||||
})
|
||||
}
|
||||
} else if (!topItem.element) {
|
||||
// Load the element if it's not cached
|
||||
const { element, ref } = findAndCloneElement(topItem.url, state.index)
|
||||
if (element) {
|
||||
topItem.element = element
|
||||
topItem.ref = ref
|
||||
}
|
||||
}
|
||||
if (newStack.length === 0) {
|
||||
window.history.replaceState(null, '', '/')
|
||||
}
|
||||
return newStack
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', onPopState)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('popstate', onPopState)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => {
|
||||
const needScrollToTop = page === currentPrimaryPage
|
||||
setPrimaryPages((prev) => {
|
||||
const exists = prev.find((p) => p.name === page)
|
||||
if (exists && props) {
|
||||
exists.props = props
|
||||
return [...prev]
|
||||
} else if (!exists) {
|
||||
return [...prev, { name: page, element: PRIMARY_PAGE_MAP[page], props }]
|
||||
}
|
||||
return prev
|
||||
})
|
||||
setCurrentPrimaryPage(page)
|
||||
if (needScrollToTop) {
|
||||
PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth')
|
||||
}
|
||||
if (enableSingleColumnLayout) {
|
||||
clearSecondaryPages()
|
||||
}
|
||||
}
|
||||
|
||||
const pushSecondaryPage = (url: string, index?: number) => {
|
||||
setSecondaryStack((prevStack) => {
|
||||
if (isCurrentPage(prevStack, url)) {
|
||||
const currentItem = prevStack[prevStack.length - 1]
|
||||
if (currentItem?.ref?.current) {
|
||||
currentItem.ref.current.scrollToTop('instant')
|
||||
}
|
||||
return prevStack
|
||||
}
|
||||
|
||||
const { newStack, newItem } = pushNewPageToStack(prevStack, url, maxStackSize, index)
|
||||
if (newItem) {
|
||||
window.history.pushState({ index: newItem.index, url }, '', url)
|
||||
}
|
||||
return newStack
|
||||
})
|
||||
}
|
||||
|
||||
const popSecondaryPage = (delta = -1) => {
|
||||
if (secondaryStack.length <= -delta) {
|
||||
// back to home page
|
||||
window.history.replaceState(null, '', '/')
|
||||
setSecondaryStack([])
|
||||
} else {
|
||||
window.history.go(delta)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSecondaryPages = () => {
|
||||
if (secondaryStack.length === 0) return
|
||||
popSecondaryPage(-secondaryStack.length)
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<PrimaryPageContext.Provider
|
||||
value={{
|
||||
navigate: navigatePrimaryPage,
|
||||
current: currentPrimaryPage,
|
||||
display: secondaryStack.length === 0
|
||||
}}
|
||||
>
|
||||
<SecondaryPageContext.Provider
|
||||
value={{
|
||||
push: pushSecondaryPage,
|
||||
pop: popSecondaryPage,
|
||||
currentIndex: secondaryStack.length
|
||||
? secondaryStack[secondaryStack.length - 1].index
|
||||
: 0
|
||||
}}
|
||||
>
|
||||
<CurrentRelaysProvider>
|
||||
<NotificationProvider>
|
||||
{!!secondaryStack.length &&
|
||||
secondaryStack.map((item, index) => (
|
||||
<div
|
||||
key={item.index}
|
||||
style={{
|
||||
display: index === secondaryStack.length - 1 ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{item.element}
|
||||
</div>
|
||||
))}
|
||||
{primaryPages.map(({ name, element, props }) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
display:
|
||||
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{props ? cloneElement(element as React.ReactElement, props) : element}
|
||||
</div>
|
||||
))}
|
||||
<BottomNavigationBar />
|
||||
<TooManyRelaysAlertDialog />
|
||||
<CreateWalletGuideToast />
|
||||
</NotificationProvider>
|
||||
</CurrentRelaysProvider>
|
||||
</SecondaryPageContext.Provider>
|
||||
</PrimaryPageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
if (enableSingleColumnLayout) {
|
||||
return (
|
||||
<PrimaryPageContext.Provider
|
||||
value={{
|
||||
navigate: navigatePrimaryPage,
|
||||
current: currentPrimaryPage,
|
||||
display: secondaryStack.length === 0
|
||||
}}
|
||||
>
|
||||
<SecondaryPageContext.Provider
|
||||
value={{
|
||||
push: pushSecondaryPage,
|
||||
pop: popSecondaryPage,
|
||||
currentIndex: secondaryStack.length
|
||||
? secondaryStack[secondaryStack.length - 1].index
|
||||
: 0
|
||||
}}
|
||||
>
|
||||
<CurrentRelaysProvider>
|
||||
<NotificationProvider>
|
||||
<div className="flex lg:justify-around w-full">
|
||||
<div className="sticky top-0 lg:w-full flex justify-end self-start h-[var(--vh)]">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex-1 w-0 bg-background border-x lg:flex-auto lg:w-[640px] lg:shrink-0">
|
||||
{!!secondaryStack.length &&
|
||||
secondaryStack.map((item, index) => (
|
||||
<div
|
||||
key={item.index}
|
||||
style={{
|
||||
display: index === secondaryStack.length - 1 ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{item.element}
|
||||
</div>
|
||||
))}
|
||||
{primaryPages.map(({ name, element, props }) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
display:
|
||||
secondaryStack.length === 0 && currentPrimaryPage === name
|
||||
? 'block'
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
{props ? cloneElement(element as React.ReactElement, props) : element}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden lg:w-full lg:block" />
|
||||
</div>
|
||||
<TooManyRelaysAlertDialog />
|
||||
<CreateWalletGuideToast />
|
||||
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
|
||||
</NotificationProvider>
|
||||
</CurrentRelaysProvider>
|
||||
</SecondaryPageContext.Provider>
|
||||
</PrimaryPageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PrimaryPageContext.Provider
|
||||
value={{
|
||||
navigate: navigatePrimaryPage,
|
||||
current: currentPrimaryPage,
|
||||
display: true
|
||||
}}
|
||||
>
|
||||
<SecondaryPageContext.Provider
|
||||
value={{
|
||||
push: pushSecondaryPage,
|
||||
pop: popSecondaryPage,
|
||||
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
|
||||
}}
|
||||
>
|
||||
<CurrentRelaysProvider>
|
||||
<NotificationProvider>
|
||||
<div className="flex flex-col items-center bg-surface-background">
|
||||
<div
|
||||
className="flex h-[var(--vh)] w-full bg-surface-background"
|
||||
style={{
|
||||
maxWidth: '1920px'
|
||||
}}
|
||||
>
|
||||
<Sidebar />
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-2 w-full',
|
||||
themeSetting === 'pure-black' ? '' : 'gap-2 pr-2 py-2'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background overflow-hidden',
|
||||
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl shadow-lg'
|
||||
)}
|
||||
>
|
||||
{primaryPages.map(({ name, element, props }) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex flex-col h-full w-full"
|
||||
style={{
|
||||
display: currentPrimaryPage === name ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{props ? cloneElement(element as React.ReactElement, props) : element}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background overflow-hidden',
|
||||
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl',
|
||||
themeSetting !== 'pure-black' && secondaryStack.length > 0 && 'shadow-lg',
|
||||
secondaryStack.length === 0 ? 'bg-surface' : ''
|
||||
)}
|
||||
>
|
||||
{secondaryStack.map((item, index) => (
|
||||
<div
|
||||
key={item.index}
|
||||
className="flex flex-col h-full w-full"
|
||||
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
|
||||
>
|
||||
{item.element}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TooManyRelaysAlertDialog />
|
||||
<CreateWalletGuideToast />
|
||||
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
|
||||
</NotificationProvider>
|
||||
</CurrentRelaysProvider>
|
||||
</SecondaryPageContext.Provider>
|
||||
</PrimaryPageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function SecondaryPageLink({
|
||||
to,
|
||||
children,
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
to: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('cursor-pointer', className)}
|
||||
onClick={(e) => {
|
||||
if (onClick) {
|
||||
onClick(e)
|
||||
}
|
||||
push(to)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function isCurrentPage(stack: TStackItem[], url: string) {
|
||||
const currentPage = stack[stack.length - 1]
|
||||
if (!currentPage) return false
|
||||
|
||||
return currentPage.url === url
|
||||
}
|
||||
|
||||
function findAndCloneElement(url: string, index: number) {
|
||||
const path = url.split('?')[0].split('#')[0]
|
||||
for (const { matcher, element } of SECONDARY_ROUTES) {
|
||||
const match = matcher(path)
|
||||
if (!match) continue
|
||||
|
||||
if (!element) return {}
|
||||
const ref = createRef<TPageRef>()
|
||||
return { element: cloneElement(element, { ...match.params, index, ref } as any), ref }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function pushNewPageToStack(
|
||||
stack: TStackItem[],
|
||||
url: string,
|
||||
maxStackSize = 5,
|
||||
specificIndex?: number
|
||||
) {
|
||||
const currentItem = stack[stack.length - 1]
|
||||
const currentIndex = specificIndex ?? (currentItem ? currentItem.index + 1 : 0)
|
||||
|
||||
const { element, ref } = findAndCloneElement(url, currentIndex)
|
||||
if (!element) return { newStack: stack, newItem: null }
|
||||
|
||||
const newItem = { element, ref, url, index: currentIndex }
|
||||
const newStack = [...stack, newItem]
|
||||
const lastCachedIndex = newStack.findIndex((stack) => stack.element)
|
||||
// Clear the oldest cached element if there are too many cached elements
|
||||
if (newStack.length - lastCachedIndex > maxStackSize) {
|
||||
newStack[lastCachedIndex].element = null
|
||||
}
|
||||
return { newStack, newItem }
|
||||
}
|
||||
24
src/assets/Icon.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export default function Icon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 1080 1228"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
xmlSpace="preserve"
|
||||
style={{
|
||||
fill: 'currentcolor',
|
||||
fillRule: 'evenodd',
|
||||
clipRule: 'evenodd',
|
||||
strokeLinejoin: 'round',
|
||||
strokeMiterlimit: 2
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
id="Icon-Curve-Cut"
|
||||
d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
10
src/assets/Logo.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import logoLight from './smeshlight.png'
|
||||
import logoDark from './smeshdark.png'
|
||||
|
||||
export default function Logo({ className }: { className?: string }) {
|
||||
const { theme } = useTheme()
|
||||
const logoSrc = theme === 'light' ? logoLight : logoDark
|
||||
|
||||
return <img src={logoSrc} alt="Smesh" className={className} />
|
||||
}
|
||||
1
src/assets/favicon.svg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
src/assets/smeshdark.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/smeshlight.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
@@ -1,5 +0,0 @@
|
||||
export const StorageKey = {
|
||||
THEME_SETTING: 'themeSetting',
|
||||
RELAY_GROUPS: 'relayGroups',
|
||||
ACCOUNT: 'account'
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
export type TRelayGroup = {
|
||||
groupName: string
|
||||
relayUrls: string[]
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type TConfig = {
|
||||
relayGroups: TRelayGroup[]
|
||||
theme: TThemeSetting
|
||||
}
|
||||
|
||||
export type TThemeSetting = 'light' | 'dark' | 'system'
|
||||
export type TTheme = 'light' | 'dark'
|
||||
|
||||
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>
|
||||
|
||||
export interface ISigner {
|
||||
getPublicKey: () => Promise<string | null>
|
||||
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
|
||||
}
|
||||
|
||||
export type TElectronWindow = {
|
||||
electron: ElectronAPI
|
||||
api: {
|
||||
system: {
|
||||
isEncryptionAvailable: () => Promise<boolean>
|
||||
getSelectedStorageBackend: () => Promise<string>
|
||||
}
|
||||
theme: {
|
||||
addChangeListener: (listener: (theme: TTheme) => void) => void
|
||||
removeChangeListener: () => void
|
||||
current: () => Promise<TTheme>
|
||||
}
|
||||
storage: {
|
||||
getItem: (key: string) => Promise<string>
|
||||
setItem: (key: string, value: string) => Promise<void>
|
||||
removeItem: (key: string) => Promise<void>
|
||||
}
|
||||
nostr: {
|
||||
login: (nsec: string) => Promise<{
|
||||
pubkey?: string
|
||||
reason?: string
|
||||
}>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
}
|
||||
nostr: ISigner
|
||||
}
|
||||
|
||||
export type TAccount = {
|
||||
signerType: 'nsec' | 'browser-nsec' | 'nip-07'
|
||||
nsec?: string
|
||||
}
|
||||
55
src/components/AboutInfoDialog/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import { CODY_PUBKEY } from '@/constants'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useState } from 'react'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="text-xl font-semibold">Smesh</div>
|
||||
<div className="text-muted-foreground">
|
||||
A user-friendly Nostr client for exploring relay feeds
|
||||
</div>
|
||||
<div>
|
||||
Made by <Username userId={CODY_PUBKEY} className="inline-block text-primary" showAt />
|
||||
</div>
|
||||
<div>
|
||||
Source code:{' '}
|
||||
<a
|
||||
href="https://git.mleku.dev/mleku/smesh"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Git
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
If you like Smesh, please consider giving it a star ⭐
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="p-4 space-y-4">{content}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>{content}</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
76
src/components/AccountList/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isSameAccount } from '@/lib/account'
|
||||
import { formatPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TAccountPointer } from '@/types'
|
||||
import { Loader, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import SignerTypeBadge from '../SignerTypeBadge'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
|
||||
export default function AccountList({
|
||||
className,
|
||||
afterSwitch
|
||||
}: {
|
||||
className?: string
|
||||
afterSwitch: () => void
|
||||
}) {
|
||||
const { accounts, account, switchAccount, removeAccount } = useNostr()
|
||||
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{accounts.map((act) => (
|
||||
<div
|
||||
key={`${act.pubkey}-${act.signerType}`}
|
||||
className={cn(
|
||||
'relative rounded-lg',
|
||||
isSameAccount(act, account) ? 'border border-primary' : 'clickable'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isSameAccount(act, account)) return
|
||||
setSwitchingAccount(act)
|
||||
switchAccount(act)
|
||||
.then(() => afterSwitch())
|
||||
.finally(() => setSwitchingAccount(null))
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center p-2">
|
||||
<div className="flex-1 flex items-center gap-2 relative">
|
||||
<SimpleUserAvatar userId={act.pubkey} />
|
||||
<div className="flex-1 w-0">
|
||||
<SimpleUsername userId={act.pubkey} className="font-semibold truncate" />
|
||||
<div className="text-sm rounded-full bg-muted px-2 w-fit">
|
||||
{formatPubkey(act.pubkey)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<SignerTypeBadge signerType={act.signerType} />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeAccount(act)
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{switchingAccount && isSameAccount(act, switchingAccount) && (
|
||||
<div className="absolute top-0 left-0 flex w-full h-full items-center justify-center rounded-lg bg-muted/60">
|
||||
<Loader size={16} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/AccountManager/BunkerLogin.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BunkerLogin({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { bunkerLogin } = useNostr()
|
||||
const [pending, setPending] = useState(false)
|
||||
const [bunkerInput, setBunkerInput] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setBunkerInput(e.target.value)
|
||||
setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (bunkerInput === '') return
|
||||
|
||||
setPending(true)
|
||||
bunkerLogin(bunkerInput)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => setErrMsg(err.message))
|
||||
.finally(() => setPending(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="bunker://..."
|
||||
value={bunkerInput}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||
</div>
|
||||
<Button onClick={handleLogin} disabled={pending}>
|
||||
<Loader className={pending ? 'animate-spin' : 'hidden'} />
|
||||
{t('Login')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
270
src/components/AccountManager/NostrConnectionLogin.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Check, Copy, Loader, ScanQrCode } from 'lucide-react'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46'
|
||||
import QrScanner from 'qr-scanner'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import QrCode from '../QrCode'
|
||||
|
||||
export default function NostrConnectLogin({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nostrConnectionLogin, bunkerLogin } = useNostr()
|
||||
const [pending, setPending] = useState(false)
|
||||
const [bunkerInput, setBunkerInput] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
const [nostrConnectionErrMsg, setNostrConnectionErrMsg] = useState<string | null>(null)
|
||||
const qrContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [qrCodeSize, setQrCodeSize] = useState(100)
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const qrScannerRef = useRef<QrScanner | null>(null)
|
||||
const qrScannerCheckTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setBunkerInput(e.target.value)
|
||||
if (errMsg) setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = (bunker: string = bunkerInput) => {
|
||||
const _bunker = bunker.trim()
|
||||
if (_bunker.trim() === '') return
|
||||
|
||||
setPending(true)
|
||||
bunkerLogin(_bunker)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => setErrMsg(err.message || 'Login failed'))
|
||||
.finally(() => setPending(false))
|
||||
}
|
||||
|
||||
const [loginDetails] = useState(() => {
|
||||
const newPrivKey = generateSecretKey()
|
||||
const newMeta: NostrConnectParams = {
|
||||
clientPubkey: getPublicKey(newPrivKey),
|
||||
relays: DEFAULT_NOSTRCONNECT_RELAY,
|
||||
secret: Math.random().toString(36).substring(7),
|
||||
name: document.location.host,
|
||||
url: document.location.origin
|
||||
}
|
||||
const newConnectionString = createNostrConnectURI(newMeta)
|
||||
return {
|
||||
privKey: newPrivKey,
|
||||
connectionString: newConnectionString
|
||||
}
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const calculateQrSize = () => {
|
||||
if (qrContainerRef.current) {
|
||||
const containerWidth = qrContainerRef.current.offsetWidth
|
||||
const desiredSizeBasedOnWidth = Math.min(containerWidth - 8, containerWidth * 0.9)
|
||||
const newSize = Math.max(100, Math.min(desiredSizeBasedOnWidth, 360))
|
||||
setQrCodeSize(newSize)
|
||||
}
|
||||
}
|
||||
|
||||
calculateQrSize()
|
||||
|
||||
const resizeObserver = new ResizeObserver(calculateQrSize)
|
||||
if (qrContainerRef.current) {
|
||||
resizeObserver.observe(qrContainerRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (qrContainerRef.current) {
|
||||
resizeObserver.unobserve(qrContainerRef.current)
|
||||
}
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginDetails.privKey || !loginDetails.connectionString) return
|
||||
setNostrConnectionErrMsg(null)
|
||||
nostrConnectionLogin(loginDetails.privKey, loginDetails.connectionString)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => {
|
||||
console.error('NostrConnectionLogin Error:', err)
|
||||
setNostrConnectionErrMsg(
|
||||
err.message ? `${err.message}. Please reload.` : 'Connection failed. Please reload.'
|
||||
)
|
||||
})
|
||||
}, [loginDetails, nostrConnectionLogin, onLoginSuccess])
|
||||
|
||||
const copyConnectionString = async () => {
|
||||
if (!loginDetails.connectionString) return
|
||||
|
||||
navigator.clipboard.writeText(loginDetails.connectionString)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const startQrScan = async () => {
|
||||
try {
|
||||
setIsScanning(true)
|
||||
setErrMsg(null)
|
||||
|
||||
// Wait for next render cycle to ensure video element is in DOM
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
if (!videoRef.current) {
|
||||
throw new Error('Video element not found')
|
||||
}
|
||||
|
||||
const hasCamera = await QrScanner.hasCamera()
|
||||
if (!hasCamera) {
|
||||
throw new Error('No camera found')
|
||||
}
|
||||
|
||||
const qrScanner = new QrScanner(
|
||||
videoRef.current,
|
||||
(result) => {
|
||||
setBunkerInput(result.data)
|
||||
stopQrScan()
|
||||
handleLogin(result.data)
|
||||
},
|
||||
{
|
||||
highlightScanRegion: true,
|
||||
highlightCodeOutline: true,
|
||||
preferredCamera: 'environment'
|
||||
}
|
||||
)
|
||||
|
||||
qrScannerRef.current = qrScanner
|
||||
await qrScanner.start()
|
||||
|
||||
// Check video feed after a delay
|
||||
qrScannerCheckTimerRef.current = setTimeout(() => {
|
||||
if (
|
||||
videoRef.current &&
|
||||
(videoRef.current.videoWidth === 0 || videoRef.current.videoHeight === 0)
|
||||
) {
|
||||
setErrMsg('Camera feed not available')
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
setErrMsg(
|
||||
`Failed to start camera: ${error instanceof Error ? error.message : 'Unknown error'}. Please check permissions.`
|
||||
)
|
||||
setIsScanning(false)
|
||||
if (qrScannerCheckTimerRef.current) {
|
||||
clearTimeout(qrScannerCheckTimerRef.current)
|
||||
qrScannerCheckTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stopQrScan = () => {
|
||||
if (qrScannerRef.current) {
|
||||
qrScannerRef.current.stop()
|
||||
qrScannerRef.current.destroy()
|
||||
qrScannerRef.current = null
|
||||
}
|
||||
setIsScanning(false)
|
||||
if (qrScannerCheckTimerRef.current) {
|
||||
clearTimeout(qrScannerCheckTimerRef.current)
|
||||
qrScannerCheckTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopQrScan()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-4">
|
||||
<div ref={qrContainerRef} className="flex flex-col items-center w-full space-y-3 mb-3">
|
||||
<a href={loginDetails.connectionString} aria-label="Open with Nostr signer app">
|
||||
<QrCode size={qrCodeSize} value={loginDetails.connectionString} />
|
||||
</a>
|
||||
{nostrConnectionErrMsg && (
|
||||
<div className="text-xs text-destructive text-center pt-1">{nostrConnectionErrMsg}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-center w-full mb-3">
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground bg-muted px-3 py-2 rounded-full cursor-pointer transition-all hover:bg-muted/80"
|
||||
style={{
|
||||
width: qrCodeSize > 0 ? `${Math.max(150, Math.min(qrCodeSize, 320))}px` : 'auto'
|
||||
}}
|
||||
onClick={copyConnectionString}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex-grow min-w-0 truncate select-none">
|
||||
{loginDetails.connectionString}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{copied ? <Check size={14} /> : <Copy size={14} />}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center w-full my-4">
|
||||
<div className="flex-grow border-t border-border/40"></div>
|
||||
<span className="px-3 text-xs text-muted-foreground">OR</span>
|
||||
<div className="flex-grow border-t border-border/40"></div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
placeholder="bunker://..."
|
||||
value={bunkerInput}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive pr-10' : 'pr-10'}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0"
|
||||
onClick={startQrScan}
|
||||
disabled={pending}
|
||||
>
|
||||
<ScanQrCode />
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => handleLogin()} disabled={pending}>
|
||||
<Loader className={pending ? 'animate-spin mr-2' : 'hidden'} />
|
||||
{t('Login')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{errMsg && <div className="text-xs text-destructive pl-3 pt-1">{errMsg}</div>}
|
||||
</div>
|
||||
<Button variant="secondary" onClick={back} className="w-full">
|
||||
{t('Back')}
|
||||
</Button>
|
||||
|
||||
<div className={cn('w-full h-full flex justify-center', isScanning ? '' : 'hidden')}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="absolute inset-0 w-full h-full bg-background"
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={stopQrScan}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/AccountManager/NpubLogin.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function NpubLogin({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { npubLogin } = useNostr()
|
||||
const [pending, setPending] = useState(false)
|
||||
const [npubInput, setNpubInput] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNpubInput(e.target.value)
|
||||
setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (npubInput === '') return
|
||||
|
||||
setPending(true)
|
||||
npubLogin(npubInput)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => setErrMsg(err.message))
|
||||
.finally(() => setPending(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="npub..."
|
||||
value={npubInput}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||
</div>
|
||||
<Button onClick={handleLogin} disabled={pending}>
|
||||
<Loader className={pending ? 'animate-spin' : 'hidden'} />
|
||||
{t('Login')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
158
src/components/AccountManager/PrivateKeyLogin.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PrivateKeyLogin({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
return (
|
||||
<Tabs defaultValue="nsec">
|
||||
<TabsList>
|
||||
<TabsTrigger value="nsec">nsec</TabsTrigger>
|
||||
<TabsTrigger value="ncryptsec">ncryptsec</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="nsec">
|
||||
<NsecLogin back={back} onLoginSuccess={onLoginSuccess} />
|
||||
</TabsContent>
|
||||
<TabsContent value="ncryptsec">
|
||||
<NcryptsecLogin back={back} onLoginSuccess={onLoginSuccess} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
function NsecLogin({ back, onLoginSuccess }: { back: () => void; onLoginSuccess: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { nsecLogin } = useNostr()
|
||||
const [nsecOrHex, setNsecOrHex] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNsecOrHex(e.target.value)
|
||||
setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (nsecOrHex === '') return
|
||||
|
||||
nsecLogin(nsecOrHex, password)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => {
|
||||
setErrMsg(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleLogin()
|
||||
}}
|
||||
>
|
||||
<div className="text-orange-400">
|
||||
{t(
|
||||
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.'
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="nsec-input">nsec or hex</Label>
|
||||
<Input
|
||||
id="nsec-input"
|
||||
type="password"
|
||||
placeholder="nsec1.. or hex"
|
||||
value={nsecOrHex}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errMsg && <div className="text-xs text-destructive">{errMsg}</div>}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password-input">{t('password')}</Label>
|
||||
<Input
|
||||
id="password-input"
|
||||
type="password"
|
||||
placeholder={t('optional: encrypt nsec')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
<Button className="flex-1" type="submit">
|
||||
{t('Login')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function NcryptsecLogin({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { ncryptsecLogin } = useNostr()
|
||||
const [ncryptsec, setNcryptsec] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNcryptsec(e.target.value)
|
||||
setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (ncryptsec === '') return
|
||||
|
||||
ncryptsecLogin(ncryptsec)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => {
|
||||
setErrMsg(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleLogin()
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ncryptsec-input">ncryptsec</Label>
|
||||
<Input
|
||||
id="ncryptsec-input"
|
||||
type="password"
|
||||
placeholder="ncryptsec1.."
|
||||
value={ncryptsec}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errMsg && <div className="text-xs text-destructive">{errMsg}</div>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
<Button className="flex-1" type="submit">
|
||||
{t('Login')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
227
src/components/AccountManager/Signup.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Check, Copy, Download, RefreshCcw } from 'lucide-react'
|
||||
import { generateSecretKey } from 'nostr-tools'
|
||||
import { nsecEncode } from 'nostr-tools/nip19'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InfoCard from '../InfoCard'
|
||||
|
||||
type Step = 'generate' | 'password'
|
||||
|
||||
export default function Signup({
|
||||
back,
|
||||
onSignupSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onSignupSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nsecLogin } = useNostr()
|
||||
const [step, setStep] = useState<Step>('generate')
|
||||
const [nsec, setNsec] = useState(generateNsec())
|
||||
const [checkedSaveKey, setCheckedSaveKey] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([nsec], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'nostr-private-key.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleSignup = async () => {
|
||||
await nsecLogin(nsec, password || undefined, true)
|
||||
onSignupSuccess()
|
||||
}
|
||||
|
||||
const passwordsMatch = password === confirmPassword
|
||||
const canSubmit = !password || passwordsMatch
|
||||
|
||||
const renderStepIndicator = () => (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{(['generate', 'password'] as Step[]).map((s, index) => (
|
||||
<div key={s} className="flex items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${
|
||||
step === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: step === 'password' && s === 'generate'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
{index < 1 && <div className="w-12 h-0.5 bg-muted mx-1" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (step === 'generate') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{renderStepIndicator()}
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold mb-2">{t('Create Your Nostr Account')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('Generate your unique private key. This is your digital identity.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoCard
|
||||
variant="alert"
|
||||
title={t('Critical: Save Your Private Key')}
|
||||
content={t(
|
||||
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>{t('Your Private Key')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={nsec}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setNsec(generateNsec())}
|
||||
title={t('Generate new key')}
|
||||
>
|
||||
<RefreshCcw />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-wrap gap-2">
|
||||
<Button onClick={handleDownload} className="flex-1">
|
||||
<Download />
|
||||
{t('Download Backup File')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(nsec)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
{copied ? t('Copied to Clipboard') : t('Copy to Clipboard')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<Checkbox
|
||||
id="acknowledge-checkbox"
|
||||
checked={checkedSaveKey}
|
||||
onCheckedChange={(c) => setCheckedSaveKey(!!c)}
|
||||
/>
|
||||
<Label htmlFor="acknowledge-checkbox" className="cursor-pointer">
|
||||
{t('I have safely backed up my private key')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={back} className="w-fit px-6">
|
||||
{t('Back')}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => setStep('password')} className="flex-1" disabled={!checkedSaveKey}>
|
||||
{t('Continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// step === 'password'
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{renderStepIndicator()}
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold mb-2">{t('Secure Your Account')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('Add an extra layer of protection with a password')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoCard
|
||||
title={t('Password Protection (Recommended)')}
|
||||
content={t(
|
||||
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password-input">{t('Password (Optional)')}</Label>
|
||||
<Input
|
||||
id="password-input"
|
||||
type="password"
|
||||
placeholder={t('Create a password (or skip)')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{password && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirm-password-input">{t('Confirm Password')}</Label>
|
||||
<Input
|
||||
id="confirm-password-input"
|
||||
type="password"
|
||||
placeholder={t('Enter your password again')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
{confirmPassword && !passwordsMatch && (
|
||||
<p className="text-xs text-red-500">{t('Passwords do not match')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setStep('generate')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
}}
|
||||
className="w-fit px-6"
|
||||
>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
<Button onClick={handleSignup} className="flex-1" disabled={!canSubmit}>
|
||||
{t('Complete Signup')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function generateNsec() {
|
||||
const sk = generateSecretKey()
|
||||
return nsecEncode(sk)
|
||||
}
|
||||
92
src/components/AccountManager/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { isDevEnv } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccountList from '../AccountList'
|
||||
import NostrConnectLogin from './NostrConnectionLogin'
|
||||
import NpubLogin from './NpubLogin'
|
||||
import PrivateKeyLogin from './PrivateKeyLogin'
|
||||
import Signup from './Signup'
|
||||
|
||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | 'signup' | null
|
||||
|
||||
export default function AccountManager({ close }: { close?: () => void }) {
|
||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{page === 'nsec' ? (
|
||||
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'bunker' ? (
|
||||
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'npub' ? (
|
||||
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'signup' ? (
|
||||
<Signup back={() => setPage(null)} onSignupSuccess={() => close?.()} />
|
||||
) : (
|
||||
<AccountManagerNav setPage={setPage} close={close} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountManagerNav({
|
||||
setPage,
|
||||
close
|
||||
}: {
|
||||
setPage: (page: TAccountManagerPage) => void
|
||||
close?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nip07Login, accounts } = useNostr()
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
|
||||
<div>
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t('Add an Account')}
|
||||
</div>
|
||||
<div className="space-y-2 mt-4">
|
||||
{!!window.nostr && (
|
||||
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
|
||||
{t('Login with Browser Extension')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setPage('bunker')} className="w-full">
|
||||
{t('Login with Bunker')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
|
||||
{t('Login with Private Key')}
|
||||
</Button>
|
||||
{isDevEnv() && (
|
||||
<Button variant="secondary" onClick={() => setPage('npub')} className="w-full">
|
||||
Login with Public key (for development)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t("Don't have an account yet?")}
|
||||
</div>
|
||||
<Button onClick={() => setPage('signup')} className="w-full mt-4">
|
||||
{t('Create New Account')}
|
||||
</Button>
|
||||
</div>
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t('Logged in Accounts')}
|
||||
</div>
|
||||
<AccountList className="mt-4" afterSwitch={() => close?.()} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
src/components/AudioPlayer/index.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import mediaManager from '@/services/media-manager.service'
|
||||
import { Minimize2, Pause, Play, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import ExternalLink from '../ExternalLink'
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string
|
||||
autoPlay?: boolean
|
||||
startTime?: number
|
||||
isMinimized?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AudioPlayer({
|
||||
src,
|
||||
autoPlay = false,
|
||||
startTime,
|
||||
isMinimized = false,
|
||||
className
|
||||
}: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [error, setError] = useState(false)
|
||||
const seekTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
const isSeeking = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (startTime) {
|
||||
setCurrentTime(startTime)
|
||||
audio.currentTime = startTime
|
||||
}
|
||||
|
||||
if (autoPlay) {
|
||||
togglePlay()
|
||||
}
|
||||
|
||||
const updateTime = () => {
|
||||
if (!isSeeking.current) {
|
||||
setCurrentTime(audio.currentTime)
|
||||
}
|
||||
}
|
||||
const updateDuration = () => setDuration(audio.duration)
|
||||
const handleEnded = () => setIsPlaying(false)
|
||||
const handlePause = () => setIsPlaying(false)
|
||||
const handlePlay = () => setIsPlaying(true)
|
||||
|
||||
audio.addEventListener('timeupdate', updateTime)
|
||||
audio.addEventListener('loadedmetadata', updateDuration)
|
||||
audio.addEventListener('ended', handleEnded)
|
||||
audio.addEventListener('pause', handlePause)
|
||||
audio.addEventListener('play', handlePlay)
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', updateTime)
|
||||
audio.removeEventListener('loadedmetadata', updateDuration)
|
||||
audio.removeEventListener('ended', handleEnded)
|
||||
audio.removeEventListener('pause', handlePause)
|
||||
audio.removeEventListener('play', handlePlay)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
const container = containerRef.current
|
||||
|
||||
if (!audio || !container) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting) {
|
||||
audio.pause()
|
||||
}
|
||||
},
|
||||
{ threshold: 1 }
|
||||
)
|
||||
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.unobserve(container)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlay = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
audio.play()
|
||||
setIsPlaying(true)
|
||||
mediaManager.play(audio)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeek = (value: number[]) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
isSeeking.current = true
|
||||
setCurrentTime(value[0])
|
||||
|
||||
if (seekTimeoutRef.current) {
|
||||
clearTimeout(seekTimeoutRef.current)
|
||||
}
|
||||
|
||||
seekTimeoutRef.current = setTimeout(() => {
|
||||
audio.currentTime = value[0]
|
||||
isSeeking.current = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ExternalLink url={src} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'flex items-center gap-3 py-2 px-2 border rounded-full max-w-md bg-background',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<audio ref={audioRef} src={src} preload="metadata" onError={() => setError(false)} />
|
||||
|
||||
{/* Play/Pause Button */}
|
||||
<Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}>
|
||||
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
|
||||
</Button>
|
||||
|
||||
{/* Progress Section */}
|
||||
<div className="flex-1 relative">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
max={duration || 100}
|
||||
step={1}
|
||||
onValueChange={handleSeek}
|
||||
hideThumb
|
||||
enableHoverAnimation
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-mono text-muted-foreground">
|
||||
{formatTime(Math.max(duration - currentTime, 0))}
|
||||
</div>
|
||||
{isMinimized ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full shrink-0 text-muted-foreground"
|
||||
onClick={() => mediaManager.stopAudioBackground()}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full shrink-0 text-muted-foreground"
|
||||
onClick={() => mediaManager.playAudioBackground(src, audioRef.current?.currentTime || 0)}
|
||||
>
|
||||
<Minimize2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (time === Infinity || isNaN(time)) {
|
||||
return '-:--'
|
||||
}
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
46
src/components/BackgroundAudio/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import mediaManager from '@/services/media-manager.service'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AudioPlayer from '../AudioPlayer'
|
||||
|
||||
export default function BackgroundAudio({ className }: { className?: string }) {
|
||||
const [backgroundAudioSrc, setBackgroundAudioSrc] = useState<string | null>(null)
|
||||
const [backgroundAudio, setBackgroundAudio] = useState<React.ReactNode>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handlePlayAudioBackground = (event: Event) => {
|
||||
const { src, time } = (event as CustomEvent).detail
|
||||
if (backgroundAudioSrc === src) return
|
||||
|
||||
setBackgroundAudio(
|
||||
<FloatingAudioPlayer key={src + time} src={src} time={time} className={className} />
|
||||
)
|
||||
setBackgroundAudioSrc(src)
|
||||
}
|
||||
|
||||
const handleStopAudioBackground = () => {
|
||||
setBackgroundAudio(null)
|
||||
}
|
||||
|
||||
mediaManager.addEventListener('playAudioBackground', handlePlayAudioBackground)
|
||||
mediaManager.addEventListener('stopAudioBackground', handleStopAudioBackground)
|
||||
|
||||
return () => {
|
||||
mediaManager.removeEventListener('playAudioBackground', handlePlayAudioBackground)
|
||||
mediaManager.removeEventListener('stopAudioBackground', handleStopAudioBackground)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return backgroundAudio
|
||||
}
|
||||
|
||||
function FloatingAudioPlayer({
|
||||
src,
|
||||
time,
|
||||
className
|
||||
}: {
|
||||
src: string
|
||||
time?: number
|
||||
className?: string
|
||||
}) {
|
||||
return <AudioPlayer src={src} className={className} startTime={time} autoPlay isMinimized />
|
||||
}
|
||||
78
src/components/BookmarkButton/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
|
||||
import { useBookmarks } from '@/providers/BookmarksProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { BookmarkIcon, Loader } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function BookmarkButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr()
|
||||
const { addBookmark, removeBookmark } = useBookmarks()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const { event } = useStuff(stuff)
|
||||
const isBookmarked = useMemo(() => {
|
||||
if (!event) return false
|
||||
|
||||
const isReplaceable = isReplaceableEvent(event.kind)
|
||||
const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id
|
||||
|
||||
return bookmarkListEvent?.tags.some((tag) =>
|
||||
isReplaceable ? tag[0] === 'a' && tag[1] === eventKey : tag[0] === 'e' && tag[1] === eventKey
|
||||
)
|
||||
}, [bookmarkListEvent, event])
|
||||
|
||||
if (!accountPubkey) return null
|
||||
|
||||
const handleBookmark = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (isBookmarked || !event) return
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
await addBookmark(event)
|
||||
} catch (error) {
|
||||
toast.error(t('Bookmark failed') + ': ' + (error as Error).message)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveBookmark = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!isBookmarked || !event) return
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
await removeBookmark(event)
|
||||
} catch (error) {
|
||||
toast.error(t('Remove bookmark failed') + ': ' + (error as Error).message)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center gap-1 ${
|
||||
isBookmarked ? 'text-rose-400' : 'text-muted-foreground'
|
||||
} enabled:hover:text-rose-400 px-3 h-full disabled:text-muted-foreground/40 disabled:cursor-default`}
|
||||
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark}
|
||||
disabled={!event || updating}
|
||||
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
|
||||
>
|
||||
{updating ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : (
|
||||
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
102
src/components/BookmarkList/index.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
|
||||
|
||||
const SHOW_COUNT = 10
|
||||
|
||||
export default function BookmarkList() {
|
||||
const { t } = useTranslation()
|
||||
const { bookmarkListEvent } = useNostr()
|
||||
const eventIds = useMemo(() => {
|
||||
if (!bookmarkListEvent) return []
|
||||
|
||||
return (
|
||||
bookmarkListEvent.tags
|
||||
.map((tag) =>
|
||||
tag[0] === 'e'
|
||||
? generateBech32IdFromETag(tag)
|
||||
: tag[0] === 'a'
|
||||
? generateBech32IdFromATag(tag)
|
||||
: null
|
||||
)
|
||||
.filter(Boolean) as (`nevent1${string}` | `naddr1${string}`)[]
|
||||
).reverse()
|
||||
}, [bookmarkListEvent])
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 0.1
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (showCount < eventIds.length) {
|
||||
setShowCount((prev) => prev + SHOW_COUNT)
|
||||
}
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore()
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [showCount, eventIds])
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return (
|
||||
<div className="mt-2 text-sm text-center text-muted-foreground">
|
||||
{t('no bookmarks found')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{eventIds.slice(0, showCount).map((eventId) => (
|
||||
<BookmarkedNote key={eventId} eventId={eventId} />
|
||||
))}
|
||||
|
||||
{showCount < eventIds.length ? (
|
||||
<div ref={bottomRef}>
|
||||
<NoteCardLoadingSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-muted-foreground mt-2">
|
||||
{t('no more bookmarks')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkedNote({ eventId }: { eventId: string }) {
|
||||
const { event, isFetching } = useFetchEvent(eventId)
|
||||
|
||||
if (isFetching) {
|
||||
return <NoteCardLoadingSkeleton className="border-b" />
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <NoteCard event={event} className="w-full" />
|
||||
}
|
||||
57
src/components/BottomNavigationBar/AccountButton.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LONG_PRESS_THRESHOLD } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { UserRound } from 'lucide-react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import LoginDialog from '../LoginDialog'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function AccountButton() {
|
||||
const { navigate, current, display } = usePrimaryPage()
|
||||
const { pubkey, profile } = useNostr()
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
||||
const active = useMemo(() => current === 'me' && display, [display, current])
|
||||
const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handlePointerDown = () => {
|
||||
pressTimerRef.current = setTimeout(() => {
|
||||
setLoginDialogOpen(true)
|
||||
pressTimerRef.current = null
|
||||
}, LONG_PRESS_THRESHOLD)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (pressTimerRef.current) {
|
||||
clearTimeout(pressTimerRef.current)
|
||||
navigate('me')
|
||||
pressTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomNavigationBarItem
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
active={active}
|
||||
>
|
||||
{pubkey ? (
|
||||
profile ? (
|
||||
<SimpleUserAvatar
|
||||
userId={pubkey}
|
||||
className={cn('size-6', active ? 'ring-primary ring-2' : '')}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className={cn('size-6 rounded-full', active ? 'ring-primary ring-2' : '')} />
|
||||
)
|
||||
) : (
|
||||
<UserRound />
|
||||
)}
|
||||
</BottomNavigationBarItem>
|
||||
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MouseEventHandler } from 'react'
|
||||
|
||||
export default function BottomNavigationBarItem({
|
||||
children,
|
||||
active = false,
|
||||
onClick,
|
||||
onPointerDown,
|
||||
onPointerUp
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
active?: boolean
|
||||
onClick?: MouseEventHandler
|
||||
onPointerDown?: MouseEventHandler
|
||||
onPointerUp?: MouseEventHandler
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'flex shadow-none items-center bg-transparent w-full h-12 p-3 m-0 rounded-lg [&_svg]:size-6',
|
||||
active && 'text-primary hover:text-primary'
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
16
src/components/BottomNavigationBar/ExploreButton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { Compass } from 'lucide-react'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function ExploreButton() {
|
||||
const { navigate, current, display } = usePrimaryPage()
|
||||
|
||||
return (
|
||||
<BottomNavigationBarItem
|
||||
active={current === 'explore' && display}
|
||||
onClick={() => navigate('explore')}
|
||||
>
|
||||
<Compass />
|
||||
</BottomNavigationBarItem>
|
||||
)
|
||||
}
|
||||
16
src/components/BottomNavigationBar/HomeButton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { Home } from 'lucide-react'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function HomeButton() {
|
||||
const { navigate, current, display } = usePrimaryPage()
|
||||
|
||||
return (
|
||||
<BottomNavigationBarItem
|
||||
active={current === 'home' && display}
|
||||
onClick={() => navigate('home')}
|
||||
>
|
||||
<Home />
|
||||
</BottomNavigationBarItem>
|
||||
)
|
||||
}
|
||||
25
src/components/BottomNavigationBar/NotificationsButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNotification } from '@/providers/NotificationProvider'
|
||||
import { Bell } from 'lucide-react'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function NotificationsButton() {
|
||||
const { checkLogin } = useNostr()
|
||||
const { navigate, current, display } = usePrimaryPage()
|
||||
const { hasNewNotification } = useNotification()
|
||||
|
||||
return (
|
||||
<BottomNavigationBarItem
|
||||
active={current === 'notifications' && display}
|
||||
onClick={() => checkLogin(() => navigate('notifications'))}
|
||||
>
|
||||
<div className="relative">
|
||||
<Bell />
|
||||
{hasNewNotification && (
|
||||
<div className="absolute -top-0.5 right-0.5 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</BottomNavigationBarItem>
|
||||
)
|
||||
}
|
||||
25
src/components/BottomNavigationBar/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import BackgroundAudio from '../BackgroundAudio'
|
||||
import AccountButton from './AccountButton'
|
||||
import ExploreButton from './ExploreButton'
|
||||
import HomeButton from './HomeButton'
|
||||
import NotificationsButton from './NotificationsButton'
|
||||
|
||||
export default function BottomNavigationBar() {
|
||||
return (
|
||||
<div
|
||||
className={cn('fixed bottom-0 w-full z-40 bg-background border-t')}
|
||||
style={{
|
||||
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||
}}
|
||||
>
|
||||
<BackgroundAudio className="rounded-none border-x-0 border-t-0 border-b bg-background" />
|
||||
<div className="w-full flex justify-around items-center [&_svg]:size-4 [&_svg]:shrink-0">
|
||||
<HomeButton />
|
||||
<ExploreButton />
|
||||
<NotificationsButton />
|
||||
<AccountButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
246
src/components/ClientSelect/index.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Drawer, DrawerContent, DrawerOverlay, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { getReplaceableEventIdentifier, getNoteBech32Id } from '@/lib/event'
|
||||
import { toChachiChat } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import clientService from '@/services/client.service'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Event, kinds, nip19 } from 'nostr-tools'
|
||||
import { Dispatch, SetStateAction, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const clients: Record<string, { name: string; getUrl: (id: string) => string }> = {
|
||||
nosta: {
|
||||
name: 'Nosta',
|
||||
getUrl: (id: string) => `https://nosta.me/${id}`
|
||||
},
|
||||
snort: {
|
||||
name: 'Snort',
|
||||
getUrl: (id: string) => `https://snort.social/${id}`
|
||||
},
|
||||
olas: {
|
||||
name: 'Olas',
|
||||
getUrl: (id: string) => `https://olas.app/e/${id}`
|
||||
},
|
||||
primal: {
|
||||
name: 'Primal',
|
||||
getUrl: (id: string) => `https://primal.net/e/${id}`
|
||||
},
|
||||
nostrudel: {
|
||||
name: 'Nostrudel',
|
||||
getUrl: (id: string) => `https://nostrudel.ninja/l/${id}`
|
||||
},
|
||||
nostter: {
|
||||
name: 'Nostter',
|
||||
getUrl: (id: string) => `https://nostter.app/${id}`
|
||||
},
|
||||
coracle: {
|
||||
name: 'Coracle',
|
||||
getUrl: (id: string) => `https://coracle.social/${id}`
|
||||
},
|
||||
iris: {
|
||||
name: 'Iris',
|
||||
getUrl: (id: string) => `https://iris.to/${id}`
|
||||
},
|
||||
lumilumi: {
|
||||
name: 'Lumilumi',
|
||||
getUrl: (id: string) => `https://lumilumi.app/${id}`
|
||||
},
|
||||
zapStream: {
|
||||
name: 'zap.stream',
|
||||
getUrl: (id: string) => `https://zap.stream/${id}`
|
||||
},
|
||||
yakihonne: {
|
||||
name: 'YakiHonne',
|
||||
getUrl: (id: string) => `https://yakihonne.com/${id}`
|
||||
},
|
||||
habla: {
|
||||
name: 'Habla',
|
||||
getUrl: (id: string) => `https://habla.news/a/${id}`
|
||||
},
|
||||
pareto: {
|
||||
name: 'Pareto',
|
||||
getUrl: (id: string) => `https://pareto.space/a/${id}`
|
||||
},
|
||||
njump: {
|
||||
name: 'Njump',
|
||||
getUrl: (id: string) => `https://njump.me/${id}`
|
||||
}
|
||||
}
|
||||
|
||||
export default function ClientSelect({
|
||||
event,
|
||||
originalNoteId,
|
||||
...props
|
||||
}: ButtonProps & {
|
||||
event?: Event
|
||||
originalNoteId?: string
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const supportedClients = useMemo(() => {
|
||||
let kind: number | undefined
|
||||
if (event) {
|
||||
kind = event.kind
|
||||
} else if (originalNoteId) {
|
||||
try {
|
||||
const pointer = nip19.decode(originalNoteId)
|
||||
if (pointer.type === 'naddr') {
|
||||
kind = pointer.data.kind
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to decode NIP-19 pointer:', error)
|
||||
return ['njump']
|
||||
}
|
||||
}
|
||||
if (!kind) {
|
||||
return ['njump']
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case kinds.LongFormArticle:
|
||||
case kinds.DraftLong:
|
||||
return ['yakihonne', 'coracle', 'habla', 'lumilumi', 'pareto', 'njump']
|
||||
case kinds.LiveEvent:
|
||||
return ['zapStream', 'nostrudel', 'njump']
|
||||
case kinds.Date:
|
||||
case kinds.Time:
|
||||
return ['coracle', 'njump']
|
||||
case kinds.CommunityDefinition:
|
||||
return ['coracle', 'snort', 'njump']
|
||||
default:
|
||||
return ['njump']
|
||||
}
|
||||
}, [event])
|
||||
|
||||
if (!originalNoteId && !event) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="space-y-2">
|
||||
{event?.kind === ExtendedKind.GROUP_METADATA ? (
|
||||
<RelayBasedGroupChatSelector
|
||||
event={event}
|
||||
originalNoteId={originalNoteId}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
) : (
|
||||
supportedClients.map((clientId) => {
|
||||
const client = clients[clientId]
|
||||
if (!client) return null
|
||||
|
||||
return (
|
||||
<ClientSelectItem
|
||||
key={clientId}
|
||||
onClick={() => setOpen(false)}
|
||||
href={client.getUrl(originalNoteId ?? getNoteBech32Id(event!))}
|
||||
name={client.name}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<Separator />
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full py-6 font-semibold"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(originalNoteId ?? getNoteBech32Id(event!))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{t('Copy event ID')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const trigger = (
|
||||
<Button variant="outline" {...props}>
|
||||
<ExternalLink /> {t('Open in another client')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerOverlay
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
<DrawerContent hideOverlay>{content}</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="px-8" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayBasedGroupChatSelector({
|
||||
event,
|
||||
originalNoteId,
|
||||
setOpen
|
||||
}: {
|
||||
event: Event
|
||||
setOpen: Dispatch<SetStateAction<boolean>>
|
||||
originalNoteId?: string
|
||||
}) {
|
||||
const { relay, id } = useMemo(() => {
|
||||
let relay: string | undefined
|
||||
if (originalNoteId) {
|
||||
const pointer = nip19.decode(originalNoteId)
|
||||
if (pointer.type === 'naddr' && pointer.data.relays?.length) {
|
||||
relay = pointer.data.relays[0]
|
||||
}
|
||||
}
|
||||
if (!relay) {
|
||||
relay = clientService.getEventHint(event.id)
|
||||
}
|
||||
|
||||
return { relay, id: getReplaceableEventIdentifier(event) }
|
||||
}, [event, originalNoteId])
|
||||
|
||||
return (
|
||||
<ClientSelectItem
|
||||
onClick={() => setOpen(false)}
|
||||
href={toChachiChat(relay, id)}
|
||||
name="Chachi Chat"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientSelectItem({
|
||||
onClick,
|
||||
href,
|
||||
name
|
||||
}: {
|
||||
onClick: () => void
|
||||
href: string
|
||||
name: string
|
||||
}) {
|
||||
return (
|
||||
<Button asChild variant="ghost" className="w-full py-6 font-semibold" onClick={onClick}>
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{name}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
17
src/components/ClientTag/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getUsingClient } from '@/lib/event'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ClientTag({ event }: { event: NostrEvent }) {
|
||||
const { t } = useTranslation()
|
||||
const usingClient = useMemo(() => getUsingClient(event), [event])
|
||||
|
||||
if (!usingClient) return null
|
||||
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{t('via {{client}}', { client: usingClient })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
76
src/components/Collapsible/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Collapsible({
|
||||
alwaysExpand = false,
|
||||
children,
|
||||
className,
|
||||
threshold = 1000,
|
||||
collapsedHeight = 600,
|
||||
...props
|
||||
}: {
|
||||
alwaysExpand?: boolean
|
||||
threshold?: number
|
||||
collapsedHeight?: number
|
||||
} & React.HTMLProps<HTMLDivElement>) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [shouldCollapse, setShouldCollapse] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (alwaysExpand || shouldCollapse) return
|
||||
|
||||
const contentEl = containerRef.current
|
||||
if (!contentEl) return
|
||||
|
||||
const checkHeight = () => {
|
||||
const fullHeight = contentEl.scrollHeight
|
||||
if (fullHeight > threshold) {
|
||||
setShouldCollapse(true)
|
||||
}
|
||||
}
|
||||
|
||||
checkHeight()
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
checkHeight()
|
||||
})
|
||||
|
||||
observer.observe(contentEl)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [alwaysExpand, shouldCollapse])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative text-left overflow-hidden', className)}
|
||||
ref={containerRef}
|
||||
{...props}
|
||||
style={{
|
||||
maxHeight: !shouldCollapse || expanded ? 'none' : `${collapsedHeight}px`
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{shouldCollapse && !expanded && (
|
||||
<div className="absolute bottom-0 h-40 w-full z-10 bg-gradient-to-b from-transparent to-background/90 flex items-end justify-center pb-4">
|
||||
<div className="bg-background rounded-lg">
|
||||
<Button
|
||||
className="bg-foreground hover:bg-foreground/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpanded(!expanded)
|
||||
}}
|
||||
>
|
||||
{t('Show more')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
src/components/Content/index.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import {
|
||||
EmbeddedEmojiParser,
|
||||
EmbeddedEventParser,
|
||||
EmbeddedHashtagParser,
|
||||
EmbeddedLNInvoiceParser,
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedUrlParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
parseContent
|
||||
} from '@/lib/content-parser'
|
||||
import { getImetaInfosFromEvent } from '@/lib/event'
|
||||
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import { TImetaInfo } from '@/types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
EmbeddedHashtag,
|
||||
EmbeddedLNInvoice,
|
||||
EmbeddedMention,
|
||||
EmbeddedNote,
|
||||
EmbeddedWebsocketUrl
|
||||
} from '../Embedded'
|
||||
import Emoji from '../Emoji'
|
||||
import ExternalLink from '../ExternalLink'
|
||||
import HighlightButton from '../HighlightButton'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import MediaPlayer from '../MediaPlayer'
|
||||
import PostEditor from '../PostEditor'
|
||||
import WebPreview from '../WebPreview'
|
||||
import XEmbeddedPost from '../XEmbeddedPost'
|
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||
|
||||
export default function Content({
|
||||
event,
|
||||
content,
|
||||
className,
|
||||
mustLoadMedia,
|
||||
enableHighlight = false
|
||||
}: {
|
||||
event?: Event
|
||||
content?: string
|
||||
className?: string
|
||||
mustLoadMedia?: boolean
|
||||
enableHighlight?: boolean
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [showHighlightEditor, setShowHighlightEditor] = useState(false)
|
||||
const [selectedText, setSelectedText] = useState('')
|
||||
const translatedEvent = useTranslatedEvent(event?.id)
|
||||
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
|
||||
const _content = translatedEvent?.content ?? event?.content ?? content
|
||||
if (!_content) return {}
|
||||
|
||||
const nodes = parseContent(_content, [
|
||||
EmbeddedEventParser,
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedUrlParser,
|
||||
EmbeddedLNInvoiceParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
EmbeddedHashtagParser,
|
||||
EmbeddedEmojiParser
|
||||
])
|
||||
|
||||
const imetaInfos = event ? getImetaInfosFromEvent(event) : []
|
||||
const allImages = nodes
|
||||
.map((node) => {
|
||||
if (node.type === 'image') {
|
||||
const imageInfo = imetaInfos.find((image) => image.url === node.data)
|
||||
if (imageInfo) {
|
||||
return imageInfo
|
||||
}
|
||||
const tag = mediaUpload.getImetaTagByUrl(node.data)
|
||||
return tag
|
||||
? getImetaInfoFromImetaTag(tag, event?.pubkey)
|
||||
: { url: node.data, pubkey: event?.pubkey }
|
||||
}
|
||||
if (node.type === 'images') {
|
||||
const urls = Array.isArray(node.data) ? node.data : [node.data]
|
||||
return urls.map((url) => {
|
||||
const imageInfo = imetaInfos.find((image) => image.url === url)
|
||||
return imageInfo ?? { url, pubkey: event?.pubkey }
|
||||
})
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
.flat() as TImetaInfo[]
|
||||
|
||||
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
|
||||
|
||||
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
|
||||
const lastNormalUrl =
|
||||
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
|
||||
|
||||
return { nodes, allImages, emojiInfos, lastNormalUrl }
|
||||
}, [event, translatedEvent, content])
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleHighlight = (text: string) => {
|
||||
setSelectedText(text)
|
||||
setShowHighlightEditor(true)
|
||||
}
|
||||
|
||||
let imageIndex = 0
|
||||
return (
|
||||
<>
|
||||
<div ref={contentRef} className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
|
||||
{nodes.map((node, index) => {
|
||||
if (node.type === 'text') {
|
||||
return node.data
|
||||
}
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
const start = imageIndex
|
||||
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
|
||||
imageIndex = end
|
||||
return (
|
||||
<ImageGallery
|
||||
className="mt-2"
|
||||
key={index}
|
||||
images={allImages}
|
||||
start={start}
|
||||
end={end}
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.type === 'media') {
|
||||
return (
|
||||
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
|
||||
)
|
||||
}
|
||||
if (node.type === 'url') {
|
||||
return <ExternalLink url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'invoice') {
|
||||
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'websocket-url') {
|
||||
return <EmbeddedWebsocketUrl url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'event') {
|
||||
const id = node.data.split(':')[1]
|
||||
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'mention') {
|
||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
||||
}
|
||||
if (node.type === 'hashtag') {
|
||||
return <EmbeddedHashtag hashtag={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'emoji') {
|
||||
const shortcode = node.data.split(':')[1]
|
||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
||||
if (!emoji) return node.data
|
||||
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
|
||||
}
|
||||
if (node.type === 'youtube') {
|
||||
return (
|
||||
<YoutubeEmbeddedPlayer
|
||||
key={index}
|
||||
url={node.data}
|
||||
className="mt-2"
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.type === 'x-post') {
|
||||
return (
|
||||
<XEmbeddedPost
|
||||
key={index}
|
||||
url={node.data}
|
||||
className="mt-2"
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||
</div>
|
||||
{enableHighlight && (
|
||||
<HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
|
||||
)}
|
||||
{enableHighlight && (
|
||||
<PostEditor
|
||||
highlightedText={selectedText}
|
||||
parentStuff={event}
|
||||
open={showHighlightEditor}
|
||||
setOpen={setShowHighlightEditor}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
src/components/ContentPreview/CommunityDefinitionPreview.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function CommunityDefinitionPreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Community')}] <span className="italic pr-0.5">{metadata.name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
src/components/ContentPreview/Content.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
EmbeddedEmojiParser,
|
||||
EmbeddedEventParser,
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedUrlParser,
|
||||
parseContent
|
||||
} from '@/lib/content-parser'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TEmoji } from '@/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EmbeddedMentionText } from '../Embedded'
|
||||
import Emoji from '../Emoji'
|
||||
|
||||
export default function Content({
|
||||
content,
|
||||
className,
|
||||
emojiInfos
|
||||
}: {
|
||||
content: string
|
||||
className?: string
|
||||
emojiInfos?: TEmoji[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const nodes = useMemo(() => {
|
||||
return parseContent(content, [
|
||||
EmbeddedEventParser,
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedUrlParser,
|
||||
EmbeddedEmojiParser
|
||||
])
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<span className={cn('pointer-events-none', className)}>
|
||||
{nodes.map((node, index) => {
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
return index > 0 ? ` [${t('Image')}]` : `[${t('Image')}]`
|
||||
}
|
||||
if (node.type === 'media') {
|
||||
return index > 0 ? ` [${t('Media')}]` : `[${t('Media')}]`
|
||||
}
|
||||
if (node.type === 'event') {
|
||||
return index > 0 ? ` [${t('Note')}]` : `[${t('Note')}]`
|
||||
}
|
||||
if (node.type === 'mention') {
|
||||
return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} />
|
||||
}
|
||||
if (node.type === 'emoji') {
|
||||
const shortcode = node.data.split(':')[1]
|
||||
const emoji = emojiInfos?.find((e) => e.shortcode === shortcode)
|
||||
if (!emoji) return node.data
|
||||
return <Emoji key={index} emoji={emoji} classNames={{ img: 'size-4' }} />
|
||||
}
|
||||
return node.data
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
23
src/components/ContentPreview/EmojiPackPreview.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getEmojiPackInfoFromEvent } from '@/lib/event-metadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function EmojiPackPreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { title, emojis } = useMemo(() => getEmojiPackInfoFromEvent(event), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Emoji Pack')}] <span className="italic pr-0.5">{title}</span>
|
||||
{emojis.length > 0 && <span>({emojis.length})</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/components/ContentPreview/FollowPackPreview.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getFollowPackInfoFromEvent } from '@/lib/event-metadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function FollowPackPreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { title } = useMemo(() => getFollowPackInfoFromEvent(event), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('truncate', className)}>
|
||||
[{t('Follow Pack')}] <span className="italic pr-0.5">{title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/components/ContentPreview/GroupMetadataPreview.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getGroupMetadataFromEvent } from '@/lib/event-metadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function GroupMetadataPreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Group')}] <span className="italic pr-0.5">{metadata.name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/components/ContentPreview/HighlightPreview.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Content from './Content'
|
||||
|
||||
export default function HighlightPreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Highlight')}]{' '}
|
||||
<Content
|
||||
content={translatedEvent?.content ?? event.content}
|
||||
emojiInfos={emojiInfos}
|
||||
className="italic pr-0.5"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/components/ContentPreview/LiveEventPreview.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function LiveEventPreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Live event')}] <span className="italic pr-0.5">{metadata.title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/components/ContentPreview/LongFormArticlePreview.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function LongFormArticlePreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Article')}] <span className="italic pr-0.5">{metadata.title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/components/ContentPreview/NormalContentPreview.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import Content from './Content'
|
||||
|
||||
export default function NormalContentPreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const translatedEvent = useTranslatedEvent(event?.id)
|
||||
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event])
|
||||
|
||||
return (
|
||||
<Content
|
||||
content={translatedEvent?.content ?? event.content}
|
||||
className={className}
|
||||
emojiInfos={emojiInfos}
|
||||
/>
|
||||
)
|
||||
}
|
||||
19
src/components/ContentPreview/PictureNotePreview.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PictureNotePreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Image')}] <span className="italic pr-0.5">{event.content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/components/ContentPreview/PollPreview.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Content from './Content'
|
||||
|
||||
export default function PollPreview({ event, className }: { event: Event; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Poll')}]{' '}
|
||||
<Content
|
||||
content={translatedEvent?.content ?? event.content}
|
||||
emojiInfos={emojiInfos}
|
||||
className="italic pr-0.5"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/ContentPreview/VideoNotePreview.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function VideoNotePreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Media')}] <span className="italic pr-0.5">{event.content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
src/components/ContentPreview/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { isMentioningMutedUsers } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CommunityDefinitionPreview from './CommunityDefinitionPreview'
|
||||
import EmojiPackPreview from './EmojiPackPreview'
|
||||
import FollowPackPreview from './FollowPackPreview'
|
||||
import GroupMetadataPreview from './GroupMetadataPreview'
|
||||
import HighlightPreview from './HighlightPreview'
|
||||
import LiveEventPreview from './LiveEventPreview'
|
||||
import LongFormArticlePreview from './LongFormArticlePreview'
|
||||
import NormalContentPreview from './NormalContentPreview'
|
||||
import PictureNotePreview from './PictureNotePreview'
|
||||
import PollPreview from './PollPreview'
|
||||
import VideoNotePreview from './VideoNotePreview'
|
||||
|
||||
export default function ContentPreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event?: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const isMuted = useMemo(
|
||||
() => (event ? mutePubkeySet.has(event.pubkey) : false),
|
||||
[mutePubkeySet, event]
|
||||
)
|
||||
const isMentioningMuted = useMemo(
|
||||
() =>
|
||||
hideContentMentioningMutedUsers && event
|
||||
? isMentioningMutedUsers(event, mutePubkeySet)
|
||||
: false,
|
||||
[event, mutePubkeySet]
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
return <div className={cn('pointer-events-none', className)}>{`[${t('Note not found')}]`}</div>
|
||||
}
|
||||
|
||||
if (isMuted) {
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>[{t('This user has been muted')}]</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMentioningMuted) {
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('This note mentions a user you muted')}]
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
kinds.ShortTextNote,
|
||||
ExtendedKind.COMMENT,
|
||||
ExtendedKind.VOICE,
|
||||
ExtendedKind.VOICE_COMMENT,
|
||||
ExtendedKind.RELAY_REVIEW
|
||||
].includes(event.kind)
|
||||
) {
|
||||
return <NormalContentPreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === kinds.Highlights) {
|
||||
return <HighlightPreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === ExtendedKind.POLL) {
|
||||
return <PollPreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === kinds.LongFormArticle) {
|
||||
return <LongFormArticlePreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
|
||||
return <VideoNotePreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === ExtendedKind.PICTURE) {
|
||||
return <PictureNotePreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === ExtendedKind.GROUP_METADATA) {
|
||||
return <GroupMetadataPreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === kinds.CommunityDefinition) {
|
||||
return <CommunityDefinitionPreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === kinds.LiveEvent) {
|
||||
return <LiveEventPreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === kinds.Emojisets) {
|
||||
return <EmojiPackPreview event={event} className={className} />
|
||||
}
|
||||
|
||||
if (event.kind === ExtendedKind.FOLLOW_PACK) {
|
||||
return <FollowPackPreview event={event} className={className} />
|
||||
}
|
||||
|
||||
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
|
||||
}
|
||||
31
src/components/CreateWalletGuideToast/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { toWallet } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function CreateWalletGuideToast() {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { profile } = useNostr()
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
profile &&
|
||||
!profile.lightningAddress &&
|
||||
!storage.hasShownCreateWalletGuideToast(profile.pubkey)
|
||||
) {
|
||||
toast(t('Set up your wallet to send and receive sats!'), {
|
||||
action: {
|
||||
label: t('Set up'),
|
||||
onClick: () => push(toWallet())
|
||||
}
|
||||
})
|
||||
storage.markCreateWalletGuideToastAsShown(profile.pubkey)
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
return null
|
||||
}
|
||||
27
src/components/Donation/PlatinumSponsors.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Image from '../Image'
|
||||
import OpenSatsLogo from './open-sats-logo.svg'
|
||||
|
||||
export default function PlatinumSponsors() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-center">{t('Platinum Sponsors')}</div>
|
||||
<div className="flex flex-col gap-2 items-center">
|
||||
<div
|
||||
className="flex items-center gap-4 cursor-pointer"
|
||||
onClick={() => window.open('https://opensats.org/', '_blank')}
|
||||
>
|
||||
<Image
|
||||
image={{
|
||||
url: OpenSatsLogo
|
||||
}}
|
||||
className="h-11"
|
||||
/>
|
||||
<div className="text-2xl font-semibold">OpenSats</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
src/components/Donation/RecentSupporters.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { formatAmount } from '@/lib/lightning'
|
||||
import lightning, { TRecentSupporter } from '@/services/lightning.service'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function RecentSupporters() {
|
||||
const { t } = useTranslation()
|
||||
const [supporters, setSupporters] = useState<TRecentSupporter[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const items = await lightning.fetchRecentSupporters()
|
||||
setSupporters(items)
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
if (!supporters.length) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-center">{t('Recent Supporters')}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{supporters.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded-md border p-2 sm:p-4 gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 w-0">
|
||||
<UserAvatar userId={item.pubkey} />
|
||||
<div className="flex-1 w-0">
|
||||
<Username className="font-semibold w-fit" userId={item.pubkey} />
|
||||
<div className="text-xs text-muted-foreground line-clamp-3 select-text">
|
||||
{item.comment}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-semibold text-yellow-400 shrink-0">
|
||||
{formatAmount(item.amount)} {t('sats')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
src/components/Donation/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SMESH_PUBKEY } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
import PlatinumSponsors from './PlatinumSponsors'
|
||||
import RecentSupporters from './RecentSupporters'
|
||||
|
||||
export default function Donation({ className }: { className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [donationAmount, setDonationAmount] = useState<number | undefined>(undefined)
|
||||
|
||||
return (
|
||||
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
|
||||
<div className="text-center font-semibold">{t('Enjoying Smesh?')}</div>
|
||||
<div className="text-center text-muted-foreground">
|
||||
{t('Your donation helps me maintain Smesh and make it better! 😊')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ amount: 1000, text: '☕️ 1k' },
|
||||
{ amount: 10000, text: '🍜 10k' },
|
||||
{ amount: 100000, text: '🍣 100k' },
|
||||
{ amount: 1000000, text: '✈️ 1M' }
|
||||
].map(({ amount, text }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=""
|
||||
key={amount}
|
||||
onClick={() => {
|
||||
setDonationAmount(amount)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<PlatinumSponsors />
|
||||
<RecentSupporters />
|
||||
<ZapDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
pubkey={SMESH_PUBKEY}
|
||||
defaultAmount={donationAmount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
src/components/Donation/open-sats-logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="344.564 330.278 111.737 91.218" width="53.87" height="43.61" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><radialGradient xlink:href="#logo_svg__a" id="logo_svg__b" cx="31.833" cy="29.662" fx="31.833" fy="29.662" r="42.553" gradientTransform="matrix(2 0 0 1.99696 -74.45 12.982)" gradientUnits="userSpaceOnUse"></radialGradient><radialGradient xlink:href="#logo_svg__a" id="logo_svg__c" cx="31.833" cy="29.662" fx="31.833" fy="29.662" r="42.553" gradientTransform="matrix(2 0 0 1.99696 -74.45 12.982)" gradientUnits="userSpaceOnUse"></radialGradient><linearGradient id="logo_svg__a"><stop style="stop-color:#ffb200;stop-opacity:1" offset="0"></stop><stop style="stop-color:#ff6b01;stop-opacity:1" offset="0.493"></stop></linearGradient></defs><path style="font-variation-settings:'wght' 700;opacity:1;fill:url(#logo_svg__b);fill-opacity:1;stroke-width:10.5833;stroke-linecap:round;stroke-linejoin:round" d="M32.574 39.319v3.81h16.11v-3.81z" transform="translate(324.22 304.883) scale(2.39915)"></path><path style="font-variation-settings:'wght' 700;fill:url(#logo_svg__c);fill-opacity:1;stroke-width:10.5833;stroke-linecap:round;stroke-linejoin:round" d="M14.85 16.062v4.551l8.944 5.681v.137l-8.945 5.68v4.551l13.029-8.555v-3.49Z" transform="translate(324.22 304.883) scale(2.39915)"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
25
src/components/DrawerMenuItem/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DrawerClose } from '@/components/ui/drawer'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function DrawerMenuItem({
|
||||
children,
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<DrawerClose className="w-full">
|
||||
<Button
|
||||
onClick={onClick}
|
||||
className={cn('w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5', className)}
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
)
|
||||
}
|
||||
14
src/components/Embedded/EmbeddedHashtag.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { toNoteList } from '@/lib/link'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
|
||||
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
|
||||
return (
|
||||
<SecondaryPageLink
|
||||
className="text-primary hover:underline"
|
||||
to={toNoteList({ hashtag: hashtag.replace('#', '') })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{hashtag}
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
64
src/components/Embedded/EmbeddedLNInvoice.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatAmount, getInvoiceDetails } from '@/lib/lightning'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import { Loader, Zap } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { checkLogin, pubkey } = useNostr()
|
||||
const [paying, setPaying] = useState(false)
|
||||
|
||||
const { amount, description } = useMemo(() => {
|
||||
return getInvoiceDetails(invoice)
|
||||
}, [invoice])
|
||||
|
||||
const handlePay = async () => {
|
||||
try {
|
||||
if (!pubkey) {
|
||||
throw new Error('You need to be logged in to zap')
|
||||
}
|
||||
setPaying(true)
|
||||
const invoiceResult = await lightning.payInvoice(invoice)
|
||||
// user canceled
|
||||
if (!invoiceResult) {
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('Lightning payment failed') + ': ' + (error as Error).message)
|
||||
} finally {
|
||||
setPaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePayClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(() => handlePay())
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('p-3 border rounded-lg cursor-default flex flex-col gap-3 max-w-sm', className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-400" />
|
||||
<div className="font-semibold text-sm">{t('Lightning Invoice')}</div>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="text-sm text-muted-foreground break-words">{description}</div>
|
||||
)}
|
||||
<div className="text-lg font-bold">
|
||||
{formatAmount(amount)} {t('sats')}
|
||||
</div>
|
||||
<Button onClick={handlePayClick}>
|
||||
{paying && <Loader className="w-4 h-4 animate-spin" />}
|
||||
{t('Pay')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/Embedded/EmbeddedMention.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import Username, { SimpleUsername } from '../Username'
|
||||
|
||||
export function EmbeddedMention({ userId, className }: { userId: string; className?: string }) {
|
||||
return (
|
||||
<Username
|
||||
userId={userId}
|
||||
showAt
|
||||
className={cn('text-primary font-normal inline', className)}
|
||||
withoutSkeleton
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmbeddedMentionText({ userId, className }: { userId: string; className?: string }) {
|
||||
return (
|
||||
<SimpleUsername userId={userId} showAt className={cn('inline', className)} withoutSkeleton />
|
||||
)
|
||||
}
|
||||
59
src/components/Embedded/EmbeddedNote.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ClientSelect from '../ClientSelect'
|
||||
import MainNoteCard from '../NoteCard/MainNoteCard'
|
||||
|
||||
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
|
||||
const { event, isFetching } = useFetchEvent(noteId)
|
||||
|
||||
if (isFetching) {
|
||||
return <EmbeddedNoteSkeleton className={className} />
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return <EmbeddedNoteNotFound className={className} noteId={noteId} />
|
||||
}
|
||||
|
||||
return (
|
||||
<MainNoteCard
|
||||
className={cn('w-full', className)}
|
||||
event={event}
|
||||
embedded
|
||||
originalNoteId={noteId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="w-9 h-9 rounded-full" />
|
||||
<div>
|
||||
<Skeleton className="h-3 w-16 my-1" />
|
||||
<Skeleton className="h-3 w-16 my-1" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-full h-4 my-1 mt-2" />
|
||||
<Skeleton className="w-2/3 h-4 my-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}>
|
||||
<div className="flex flex-col items-center text-muted-foreground font-medium gap-2">
|
||||
<div>{t('Sorry! The note cannot be found 😔')}</div>
|
||||
<ClientSelect className="w-full mt-2" originalNoteId={noteId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/components/Embedded/EmbeddedWebsocketUrl.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { toRelay } from '@/lib/link'
|
||||
|
||||
export function EmbeddedWebsocketUrl({ url }: { url: string }) {
|
||||
const { push } = useSecondaryPage()
|
||||
return (
|
||||
<span
|
||||
className="cursor-pointer px-1 text-primary hover:bg-primary/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toRelay(url))
|
||||
}}
|
||||
>
|
||||
[ {url} ]
|
||||
<span className="w-2 h-1 bg-primary" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
5
src/components/Embedded/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './EmbeddedHashtag'
|
||||
export * from './EmbeddedLNInvoice'
|
||||
export * from './EmbeddedMention'
|
||||
export * from './EmbeddedNote'
|
||||
export * from './EmbeddedWebsocketUrl'
|
||||