Compare commits
504 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
384c16e29d | ||
|
|
789982bd76 | ||
|
|
8bccc9de48 | ||
|
|
ec8584b4d2 | ||
|
|
54bd59fa2d | ||
|
|
b19f5f55f7 | ||
|
|
0964f25f97 | ||
|
|
5f3e6335c1 | ||
|
|
f30c894c87 | ||
|
|
bec769ac1b | ||
|
|
cb3748e06f | ||
|
|
d5a24f0a46 | ||
|
|
401a8241bd | ||
|
|
2193a7a863 | ||
|
|
e6bc4d7fda | ||
|
|
aee9f73316 | ||
|
|
aef7b4cea4 | ||
|
|
c9a8a3b91e | ||
|
|
0c7b11bdf8 | ||
|
|
8c151a5855 | ||
|
|
9b54fa9c14 | ||
|
|
99d7705404 | ||
|
|
eaa590b8e2 | ||
|
|
715fd8cf10 | ||
|
|
99a9709605 | ||
|
|
65d330d5ed | ||
|
|
1d1d389a03 | ||
|
|
0392389355 | ||
|
|
cf2a500a07 | ||
|
|
7d3748202e | ||
|
|
d7f90faea9 | ||
|
|
cb0066aac9 | ||
|
|
b48397b7a6 | ||
|
|
82ab8419e3 | ||
|
|
142a2414d3 | ||
|
|
081bd95f60 | ||
|
|
300aed0589 | ||
|
|
b2b23c66cf | ||
|
|
838bb6aa3d | ||
|
|
f14ecc5acb | ||
|
|
d533e23dc0 | ||
|
|
eefcf99364 | ||
|
|
1c0790bfb6 | ||
|
|
29e351ba78 | ||
|
|
7592c5c327 | ||
|
|
f5018204ab | ||
|
|
7ae74268fd | ||
|
|
52e959a7f5 | ||
|
|
4f03a2c276 | ||
|
|
bc4c96ee35 | ||
|
|
a866040fc1 | ||
|
|
c90fad268a | ||
|
|
8ef1f775f9 | ||
|
|
90af87339c | ||
|
|
9007b1ca71 | ||
|
|
0b7e6145de | ||
|
|
bf1b608d96 | ||
|
|
7db0f2a05c | ||
|
|
165b4d4b9f | ||
|
|
a7106138c4 | ||
|
|
a498bfab38 | ||
|
|
3dd2980283 | ||
|
|
2e2a1a2c9d | ||
|
|
b9666bf037 | ||
|
|
ab1e964d3a | ||
|
|
1500744a96 | ||
|
|
394311622d | ||
|
|
c7f3991ddd | ||
|
|
e05efaa4f6 | ||
|
|
c96347a331 | ||
|
|
d721e84e42 | ||
|
|
dcbe4bd23e | ||
|
|
e11184426e | ||
|
|
ebea872c72 | ||
|
|
8e57d3d491 | ||
|
|
ca339ac0b2 | ||
|
|
abb6819c40 | ||
|
|
de314894ff | ||
|
|
2939747ebf | ||
|
|
a4548306e7 | ||
|
|
f16c1720a6 | ||
|
|
5b2ee94062 | ||
|
|
3091ad7fd4 | ||
|
|
5b7488295c | ||
|
|
bea62ddc4b | ||
|
|
44d6b1fb2a | ||
|
|
02ec8dd936 | ||
|
|
765ce0ac5e | ||
|
|
a1f7c3e34a | ||
|
|
2e5eb08b54 | ||
|
|
46a6d4fe0c | ||
|
|
84ea0df550 | ||
|
|
0f58b166ce | ||
|
|
f65d39023c | ||
|
|
0b3c7efbc1 | ||
|
|
ecb462562f | ||
|
|
c5a3d00371 | ||
|
|
d3b7a8ddde | ||
|
|
0eee203a9b | ||
|
|
cd5a95dea3 | ||
|
|
f348ddaf73 | ||
|
|
9f09093c80 | ||
|
|
490c6c9bdc | ||
|
|
4eb0ede76b | ||
|
|
02c1b6b783 | ||
|
|
9eed448da6 | ||
|
|
f8d621bcdc | ||
|
|
5cbe2246d3 | ||
|
|
f29a180cbd | ||
|
|
0ca3771906 | ||
|
|
6dab126f88 | ||
|
|
6c74d04984 | ||
|
|
1e00ff5e35 | ||
|
|
71fa334f61 | ||
|
|
d3ee995221 | ||
|
|
6812584b8c | ||
|
|
47ddf8ebe1 | ||
|
|
36897e7f15 | ||
|
|
f18315be02 | ||
|
|
38d77b02f5 | ||
|
|
5b77a93bba | ||
|
|
e1c11a7450 | ||
|
|
d96ee50f5a | ||
|
|
d4a172ba7e | ||
|
|
52ddb8dd7d | ||
|
|
8c16614752 | ||
|
|
700d7cc5fa | ||
|
|
017703dab2 | ||
|
|
c59fdb14f1 | ||
|
|
0c104f95d9 | ||
|
|
acbefae501 | ||
|
|
2ce83ef88a | ||
|
|
dab3412ecd | ||
|
|
988b3164d2 | ||
|
|
4161053821 | ||
|
|
60054c4865 | ||
|
|
f4e8aa576c | ||
|
|
30a495bcd1 | ||
|
|
6dde0eb220 | ||
|
|
90d8ef3423 | ||
|
|
f626a8ec9b | ||
|
|
a7c7535236 | ||
|
|
5b0f2821d6 | ||
|
|
be045557b8 | ||
|
|
a0c92182f9 | ||
|
|
f33d33556b | ||
|
|
9aff889835 | ||
|
|
420df1fbdd | ||
|
|
2946ede5ac | ||
|
|
6ec28e6a9d | ||
|
|
820daa489e | ||
|
|
b162596013 | ||
|
|
e581237e16 | ||
|
|
fcc329cc7c | ||
|
|
c9544e0fd2 | ||
|
|
d7906cfb95 | ||
|
|
13cd6aeb11 | ||
|
|
d4821d18fb | ||
|
|
b86bf48382 | ||
|
|
c595f94567 | ||
|
|
82058c0ef4 | ||
|
|
a1f3424b38 | ||
|
|
14ab749ef1 | ||
|
|
61dd4b2089 | ||
|
|
fb2fe1cc63 | ||
|
|
720f12ce1c | ||
|
|
423ebb403f | ||
|
|
c90fb66bb8 | ||
|
|
188de7ab1d | ||
|
|
0b1cf267a7 | ||
|
|
19f68612a5 | ||
|
|
1b1600d6f2 | ||
|
|
ce67c19ece | ||
|
|
f754ce3cfe | ||
|
|
19a86525cb | ||
|
|
29213ceb1c | ||
|
|
d25a9b1735 | ||
|
|
0f03706166 | ||
|
|
b1f79e3844 | ||
|
|
243d9b17ef | ||
|
|
50a6cf6499 | ||
|
|
8f7991e971 | ||
|
|
0aba54bd23 | ||
|
|
23833b2cff | ||
|
|
d5076ff53e | ||
|
|
41e452be1e | ||
|
|
f267df8f60 | ||
|
|
7426c9d1fc | ||
|
|
93d0c1052b | ||
|
|
6537650757 | ||
|
|
a95f9b522b | ||
|
|
47d1335842 | ||
|
|
168095e133 | ||
|
|
5c7b413a8d | ||
|
|
bca6458e44 | ||
|
|
ebdfa3b5a3 | ||
|
|
17480dddbf | ||
|
|
2a422fbeb9 | ||
|
|
22961ee479 | ||
|
|
18db905974 | ||
|
|
689963c041 | ||
|
|
3f8869fd75 | ||
|
|
129aced1a2 | ||
|
|
69febf4356 | ||
|
|
65051c9c1f | ||
|
|
ba8229d464 | ||
|
|
9251b017d4 | ||
|
|
1ae76031f3 | ||
|
|
994d834a0b | ||
|
|
67a4e17055 | ||
|
|
1e82e3f240 | ||
|
|
f973c75ff5 | ||
|
|
28316a71c5 | ||
|
|
cfc12e2d78 | ||
|
|
7464a8b505 | ||
|
|
938d79663b | ||
|
|
cc0ad69275 | ||
|
|
810ff060f8 | ||
|
|
5e03ef70a6 | ||
|
|
f05fb29c7b | ||
|
|
e737b1f7f0 | ||
|
|
21a7be2f98 | ||
|
|
4c720aa049 | ||
|
|
6b240b01ec | ||
|
|
945894e3db | ||
|
|
667397e528 | ||
|
|
e4b0d6d1cd | ||
|
|
3cdda2dcb7 | ||
|
|
876ecc808d | ||
|
|
34671bd067 | ||
|
|
a6285f6a1d | ||
|
|
36508d600a | ||
|
|
a304bb7c26 | ||
|
|
04bab96a07 | ||
|
|
22ebbff755 | ||
|
|
b43f40597f | ||
|
|
fe3af25c5f | ||
|
|
ffafc6f64d | ||
|
|
eadab9a37f | ||
|
|
13b1692931 | ||
|
|
3a78289fee | ||
|
|
12c70b06de | ||
|
|
c7c82954ad | ||
|
|
3b639e2783 | ||
|
|
946584236d | ||
|
|
aadbf2084f | ||
|
|
3d7b649cba | ||
|
|
caa07012a7 | ||
|
|
ad5cd875de | ||
|
|
0a4bc2cfbb | ||
|
|
605dd41939 | ||
|
|
8679ae7a37 | ||
|
|
3c1e4312c9 | ||
|
|
53ed6849af | ||
|
|
4b95e6c262 | ||
|
|
40ab215c4d | ||
|
|
823927525f | ||
|
|
6277824b32 | ||
|
|
f94e4ba900 | ||
|
|
acf14ccee9 | ||
|
|
f882b63359 | ||
|
|
7b1e3be39b | ||
|
|
ee17018076 | ||
|
|
1dd2e1dc38 | ||
|
|
4cd1aa89ad | ||
|
|
e667cf05c2 | ||
|
|
7512375728 | ||
|
|
f108e2e70a | ||
|
|
daa43ec4c4 | ||
|
|
ab2223e739 | ||
|
|
e8cbb3af4b | ||
|
|
f374a9af28 | ||
|
|
f2cbc66a97 | ||
|
|
1627d4f53e | ||
|
|
b93a4d072a | ||
|
|
3e4bc97684 | ||
|
|
3c0c20f61c | ||
|
|
dae63e210b | ||
|
|
dc500cc296 | ||
|
|
1fc1e4f102 | ||
|
|
524b5e1559 | ||
|
|
930de76d1f | ||
|
|
b85fc820d1 | ||
|
|
b145aee29d | ||
|
|
a0e65a48f1 | ||
|
|
ccdfc54cdc | ||
|
|
61ce338b8c | ||
|
|
47de9a75b7 | ||
|
|
607f3d46f0 | ||
|
|
bdbc08fdf1 | ||
|
|
3a28160ae8 | ||
|
|
e03696eed7 | ||
|
|
f80fa3de7f | ||
|
|
4518fc16a7 | ||
|
|
7f2b70779b | ||
|
|
cc9cc47b51 | ||
|
|
a19cb8b6dc | ||
|
|
c564d1608b | ||
|
|
c146a8f7ec | ||
|
|
48cde27a5b | ||
|
|
fdf0644bbb | ||
|
|
ec7371c43b | ||
|
|
35204ee400 | ||
|
|
d1031b3342 | ||
|
|
db67e94b9e | ||
|
|
a0e5ba3a63 | ||
|
|
f3f80449a6 | ||
|
|
bd0b4e848f | ||
|
|
4f5ba99214 | ||
|
|
aab67d8375 | ||
|
|
dbc0a48194 | ||
|
|
6a84646b0b | ||
|
|
e921967082 | ||
|
|
ec34bc3d04 | ||
|
|
96ce12b952 | ||
|
|
1066c43d6c | ||
|
|
914557a61d | ||
|
|
3df2f248ff | ||
|
|
d2770d58e2 | ||
|
|
933182567d | ||
|
|
f9fa2f05f0 | ||
|
|
919bb8151f | ||
|
|
6f82674c9b | ||
|
|
8caf9988fc | ||
|
|
036ee20d98 | ||
|
|
b86545dcc8 | ||
|
|
8bdccd9c9e | ||
|
|
9a14185fa5 | ||
|
|
53a6053464 | ||
|
|
e27d7ee26c | ||
|
|
98203e6b6f | ||
|
|
8469740141 | ||
|
|
8fff2bce52 | ||
|
|
30b98fc744 | ||
|
|
7a190b7d35 | ||
|
|
e3149c40c7 | ||
|
|
91743518bd | ||
|
|
fd2e4079ab | ||
|
|
ec423cad80 | ||
|
|
8f8441b0e0 | ||
|
|
3c20d45dba | ||
|
|
75c4e20dc9 | ||
|
|
9d27595d31 | ||
|
|
b7d90a790b | ||
|
|
c49d850f74 | ||
|
|
4c11c5fc54 | ||
|
|
44befab6d3 | ||
|
|
02a2f4b85e | ||
|
|
43d54b5734 | ||
|
|
b7896be507 | ||
|
|
eeb40306da | ||
|
|
749b47ac5c | ||
|
|
42f59f2b19 | ||
|
|
2bf6e742f1 | ||
|
|
2a2049e678 | ||
|
|
146aa85e76 | ||
|
|
a26c7497b5 | ||
|
|
da67135f5e | ||
|
|
aebb6d1762 | ||
|
|
8f5cf6a0b4 | ||
|
|
875017db96 | ||
|
|
c0f34b684d | ||
|
|
613956bbaf | ||
|
|
041ba5c05b | ||
|
|
05c21cfd6d | ||
|
|
4898f99ae1 | ||
|
|
be920e8c44 | ||
|
|
0fa5ac536b | ||
|
|
cef359af29 | ||
|
|
2de72b73c1 | ||
|
|
a794331c1a | ||
|
|
e09be543bc | ||
|
|
88085c48d2 | ||
|
|
e32010771b | ||
|
|
03e7484e71 | ||
|
|
d9fd4ec286 | ||
|
|
8f14f0347c | ||
|
|
9b5bb8496f | ||
|
|
9264a78c95 | ||
|
|
326d571871 | ||
|
|
744e86b290 | ||
|
|
e46b68da7e | ||
|
|
811a962632 | ||
|
|
eb82e8762a | ||
|
|
d919da153f | ||
|
|
8389d5811a | ||
|
|
0aa0c44441 | ||
|
|
49ea7504a1 | ||
|
|
6602fb9359 | ||
|
|
731eb6915a | ||
|
|
3459179310 | ||
|
|
b1f951daf5 | ||
|
|
caebcec0af | ||
|
|
5f50f4b8d6 | ||
|
|
3039208ba0 | ||
|
|
397c956e87 | ||
|
|
cf47ceb74b | ||
|
|
da7aa2c115 | ||
|
|
c0046bc04c | ||
|
|
2f8f6a0652 | ||
|
|
9a6f788b98 | ||
|
|
c1a628260c | ||
|
|
7b0bd7077c | ||
|
|
7d47f0a86e | ||
|
|
44fcd74cbe | ||
|
|
5ac0e7ed87 | ||
|
|
743968f7fb | ||
|
|
e1a3ae4b4d | ||
|
|
acf13448ae | ||
|
|
a5daa8b56c | ||
|
|
267169c5c1 | ||
|
|
89272dd9a3 | ||
|
|
d059212238 | ||
|
|
0d8a3576a6 | ||
|
|
8910c2750a | ||
|
|
12393d6df4 | ||
|
|
6c0a2439ad | ||
|
|
d83712127b | ||
|
|
55325cd7ad | ||
|
|
82e508fca6 | ||
|
|
8ff32e9363 | ||
|
|
477308632b | ||
|
|
9ffd06f5e3 | ||
|
|
a89c87819a | ||
|
|
b09ae3bae3 | ||
|
|
6ea8c0d40e | ||
|
|
079501337c | ||
|
|
5bf0382227 | ||
|
|
0199c59014 | ||
|
|
44fb63fc59 | ||
|
|
13a28d2dbd | ||
|
|
f87a7da32e | ||
|
|
8fdf9938c2 | ||
|
|
ee4d480961 | ||
|
|
bd866549a0 | ||
|
|
7c39f1d821 | ||
|
|
e6a7bb4c98 | ||
|
|
14cf3189b8 | ||
|
|
66b060627a | ||
|
|
d9bcf14baa | ||
|
|
c571e6ebf7 | ||
|
|
fb06a1aec3 | ||
|
|
5a0d08641b | ||
|
|
8a8419385e | ||
|
|
0d5dc6e785 | ||
|
|
1d90333803 | ||
|
|
91e6e62688 | ||
|
|
619a8a9753 | ||
|
|
0fe38e94d3 | ||
|
|
722e8adbdf | ||
|
|
886d5ac08c | ||
|
|
89d5ba4c37 | ||
|
|
b8b9f82d91 | ||
|
|
b3fc9bb5c3 | ||
|
|
d2ebcd8fbe | ||
|
|
68c9623c35 | ||
|
|
496d1df404 | ||
|
|
ea1046fe13 | ||
|
|
6d58d6e7f3 | ||
|
|
e1420140d1 | ||
|
|
484c2e0c2f | ||
|
|
31f7d53829 | ||
|
|
e3debfa5df | ||
|
|
a1305fba81 | ||
|
|
ca95d6c7f4 | ||
|
|
5513fc9850 | ||
|
|
86de98e644 | ||
|
|
fd374cd705 | ||
|
|
20b4658bef | ||
|
|
0850ba250c | ||
|
|
b71d188fd8 | ||
|
|
579f6b9a96 | ||
|
|
d9403a73c6 | ||
|
|
747811fa94 | ||
|
|
489e480394 | ||
|
|
418bcb0295 | ||
|
|
88f01554e7 | ||
|
|
c85092a644 | ||
|
|
096478bcec | ||
|
|
b8de4a85e0 | ||
|
|
a5b7cedfaa | ||
|
|
0adb8d6766 | ||
|
|
6a6b8c4fad | ||
|
|
4f952816ea | ||
|
|
76835e2509 | ||
|
|
63af770c83 | ||
|
|
165c427e5f | ||
|
|
a0e30aa197 | ||
|
|
3a8203d26e | ||
|
|
ffe848883e | ||
|
|
078a13c45d | ||
|
|
8a69d5bc6b | ||
|
|
6783ff23f9 | ||
|
|
72a264a01e | ||
|
|
5a67be8096 | ||
|
|
9a929a6be4 | ||
|
|
e0ca010026 | ||
|
|
8bd5d7aadf | ||
|
|
9115c38cde | ||
|
|
0c7c1d54d9 | ||
|
|
d529d83eb8 | ||
|
|
a3127c7836 | ||
|
|
4d5fe1f425 | ||
|
|
c7a4de9786 |
@@ -5,4 +5,4 @@ alwaysApply: false
|
|||||||
|
|
||||||
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
||||||
|
|
||||||
Never write "Loading" - always show a spinner, and just a spinner.
|
Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate).
|
||||||
|
|||||||
8
.cursor/rules/mobile-first-ui-ux.mdc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
description: anything related to UI/UX
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||||
|
|
||||||
|
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.
|
||||||
3
.gitignore
vendored
@@ -8,6 +8,7 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Applesauce Reference
|
# Reference Projects
|
||||||
applesauce
|
applesauce
|
||||||
|
primal-web-app
|
||||||
|
|
||||||
|
|||||||
880
CHANGELOG.md
89
FEATURES.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Boris Features
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- **Purpose**: A calm, fast, Nostr‑first reader that turns your bookmarks into a focused reading app.
|
||||||
|
- **Layout**: Three‑pane interface — bookmarks (left), reader (center), highlights (right). Collapsible sidebars.
|
||||||
|
- **Content**: Renders both Nostr long‑form posts (kind:30023) and regular web URLs.
|
||||||
|
- **Social layer**: Highlights shown by level — mine, friends, nostrverse — each with its own color and visibility toggle.
|
||||||
|
|
||||||
|
## Reader Experience
|
||||||
|
|
||||||
|
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
||||||
|
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
||||||
|
- **Progress**: Reading progress indicator with completion state.
|
||||||
|
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
||||||
|
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
||||||
|
|
||||||
|
## Highlights (NIP‑84)
|
||||||
|
|
||||||
|
- **Levels**: Mine, friends, nostrverse; toggle per level; colors configurable in settings.
|
||||||
|
- **Interactions**: Click a highlight to scroll to its position; count indicator in the header.
|
||||||
|
- **Creation**: Select text and use the floating highlighter button to publish a highlight.
|
||||||
|
- **Attribution**: Automatically tags article authors for Nostr posts so they can see highlights.
|
||||||
|
|
||||||
|
## Zap Splits (NIP‑57)
|
||||||
|
|
||||||
|
- **Configurable splits**: Weight‑based sliders for highlighter, author(s), and Boris (defaults 50/50/2.1).
|
||||||
|
- **Presets**: Quick buttons for common split configurations.
|
||||||
|
- **Respect source**: If the source article has zap tags, author weights are proportionally preserved.
|
||||||
|
|
||||||
|
## Bookmarks & Reading List (NIP‑51 + Web)
|
||||||
|
|
||||||
|
- **Ingestion**: Collects list bookmarks and items from kinds 10003/30003/30001.
|
||||||
|
- **Web bookmarks**: Supports NIP‑B0 (kind:39701) for standalone URL bookmarks.
|
||||||
|
- **Add Bookmark**: Modal with auto title/description extraction and keywords/tags suggestion (adds “boris” when helpful).
|
||||||
|
- **Views**: Reading list in compact, cards, or large preview modes; quick toggles to switch.
|
||||||
|
- **Archive**: “Read” items appear in your archive; can mark articles/web pages as read.
|
||||||
|
|
||||||
|
## Explore & Profiles
|
||||||
|
|
||||||
|
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
|
||||||
|
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
|
||||||
|
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- **Supporter page**: Displays avatars of users who zapped Boris (kind:9735 receipts).
|
||||||
|
- **Thresholds**: Shows supporters who sent ≥ 2100 sats; whales (≥ 69420 sats) get special styling with a bolt badge.
|
||||||
|
- **Profile integration**: Fetches and displays profile pictures and names for all supporters.
|
||||||
|
- **Stats**: Total supporter count and zap count displayed at the bottom.
|
||||||
|
|
||||||
|
## Video
|
||||||
|
|
||||||
|
- **Embedded player**: Plays supported videos (e.g., YouTube) inline with duration display.
|
||||||
|
- **Metadata**: Fetches YouTube title/description/transcript when available.
|
||||||
|
- **Deep links**: Open in native apps via platform‑specific URL schemes.
|
||||||
|
|
||||||
|
## Settings (NIP‑78 Application Data)
|
||||||
|
|
||||||
|
- **Theme**: System/light/dark with color variants (dark: black/midnight/charcoal; light: paper‑white/sepia/ivory).
|
||||||
|
- **Reading**: Font family (preloaded), font size, highlight style (marker/underline), per‑level colors.
|
||||||
|
- **Layout & startup**: Default view modes, auto‑collapse preferences, show/hide highlights.
|
||||||
|
- **Zap Splits**: Weight sliders and presets for NIP‑57 splits.
|
||||||
|
- **Offline/Flight Mode**: Local image cache with size limit and clear controls; “use local relay as cache”; rebroadcast preferences.
|
||||||
|
- **Relays**: Relay overview and status in Settings; educational links.
|
||||||
|
- **PWA**: Install prompt when available.
|
||||||
|
|
||||||
|
## Offline, PWA, and Sync
|
||||||
|
|
||||||
|
- **PWA**: Installable; service worker registered; periodic update checks with in‑app toast.
|
||||||
|
- **Flight Mode**: Operates with local relays only; highlights created offline are stored locally and synced later.
|
||||||
|
- **Relay indicator**: Floating status indicator shows Connecting/Offline/Flight Mode and connected counts.
|
||||||
|
|
||||||
|
## Relays & Accounts
|
||||||
|
|
||||||
|
- **Applesauce stack**: Accounts, event store, relay pool, and blueprints power Nostr interactions.
|
||||||
|
- **Multi‑relay**: Grouped connections with keep‑alive subscription; local+remote partitioning for fast queries.
|
||||||
|
- **Persistence**: Accounts restored from local storage; settings saved to NIP‑78 and watched for updates.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
- **Identity**: No email or new account; uses your existing Nostr signer/identity.
|
||||||
|
- **Data**: Bookmarks and highlights live on Nostr; reading/rendering happens locally in your browser.
|
||||||
|
|
||||||
|
## Conveniences
|
||||||
|
|
||||||
|
- **Share/copy**: One‑click copy or share for articles and videos.
|
||||||
|
- **Open on Nostr**: Deep links to portals and `nostr:` schemes for long‑form articles.
|
||||||
|
- **Mobile UX**: Floating open buttons for Bookmarks/Highlights, focus trapping, and backdrop controls.
|
||||||
|
|
||||||
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Gigi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# Mobile Implementation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Boris is now mobile-friendly! The app now works seamlessly on mobile devices with a responsive design that includes:
|
|
||||||
- Auto-collapsing sidebar that opens as an overlay drawer on small screens
|
|
||||||
- Touch-optimized UI with proper touch target sizes (44x44px minimum)
|
|
||||||
- Safe area insets for notched devices (iPhone X+, etc.)
|
|
||||||
- Focus trap and keyboard navigation in the mobile sidebar
|
|
||||||
- Mobile-optimized modals, toasts, and other UI elements
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. Viewport & Base Setup
|
|
||||||
**File: `index.html`**
|
|
||||||
- Updated viewport meta tag to include `viewport-fit=cover` for proper safe area handling
|
|
||||||
|
|
||||||
### 2. Media Query Hooks
|
|
||||||
**File: `src/hooks/useMediaQuery.ts` (NEW)**
|
|
||||||
- `useMediaQuery(query)` - Generic hook for any media query
|
|
||||||
- `useIsMobile()` - Detects mobile viewport (≤768px)
|
|
||||||
- `useIsTablet()` - Detects tablet viewport (≤1024px)
|
|
||||||
- `useIsCoarsePointer()` - Detects touch devices
|
|
||||||
|
|
||||||
### 3. Mobile CSS Styles
|
|
||||||
**File: `src/index.css`**
|
|
||||||
- Added CSS custom properties for mobile breakpoints and safe areas
|
|
||||||
- Mobile-specific three-pane layout that stacks into single column
|
|
||||||
- Overlay sidebar with backdrop and transitions
|
|
||||||
- Touch target improvements (44x44px minimum)
|
|
||||||
- Disabled hover effects on touch devices
|
|
||||||
- Mobile-optimized modals (full-screen sheet style)
|
|
||||||
- Mobile-optimized toasts (bottom position with safe area)
|
|
||||||
- Dynamic viewport height support (`100dvh`)
|
|
||||||
- Overscroll behavior and body scroll locking
|
|
||||||
|
|
||||||
### 4. Sidebar State Management
|
|
||||||
**File: `src/hooks/useBookmarksUI.ts`**
|
|
||||||
- Added `isMobile` state from media query
|
|
||||||
- Added `isSidebarOpen` state for mobile overlay
|
|
||||||
- Added `toggleSidebar()` function
|
|
||||||
- Auto-collapse logic based on `autoCollapseSidebarOnMobile` setting
|
|
||||||
- Mobile sidebar defaults to closed, desktop defaults to open
|
|
||||||
|
|
||||||
### 5. Three-Pane Layout Mobile Support
|
|
||||||
**File: `src/components/ThreePaneLayout.tsx`**
|
|
||||||
- Mobile hamburger button (visible only on mobile)
|
|
||||||
- Mobile backdrop for closing sidebar
|
|
||||||
- Body scroll locking when sidebar is open
|
|
||||||
- ESC key handler to close sidebar
|
|
||||||
- Focus trap in sidebar (Tab navigation stays within sidebar)
|
|
||||||
- Focus restoration when closing sidebar
|
|
||||||
- Accessibility attributes (`aria-hidden`, `aria-expanded`, etc.)
|
|
||||||
|
|
||||||
### 6. Sidebar Header Mobile Controls
|
|
||||||
**File: `src/components/SidebarHeader.tsx`**
|
|
||||||
- Close button (X) visible on mobile instead of collapse chevron
|
|
||||||
- Hamburger button hidden in header (shown in layout instead)
|
|
||||||
|
|
||||||
### 7. Bookmark List Mobile Props
|
|
||||||
**File: `src/components/BookmarkList.tsx`**
|
|
||||||
- Added `isMobile` prop support
|
|
||||||
- Passes mobile state to SidebarHeader
|
|
||||||
|
|
||||||
### 8. Main Bookmarks Component
|
|
||||||
**File: `src/components/Bookmarks.tsx`**
|
|
||||||
- Uses mobile state from `useBookmarksUI`
|
|
||||||
- Auto-closes sidebar when selecting bookmark on mobile
|
|
||||||
- Closes sidebar when opening settings on mobile
|
|
||||||
- Proper desktop/mobile toggle behavior
|
|
||||||
|
|
||||||
### 9. Icon Button Enhancement
|
|
||||||
**File: `src/components/IconButton.tsx`**
|
|
||||||
- Added optional `className` prop for additional styling
|
|
||||||
|
|
||||||
### 10. Mobile Settings
|
|
||||||
**File: `src/services/settingsService.ts`**
|
|
||||||
- Added `autoCollapseSidebarOnMobile?: boolean` setting (default: true)
|
|
||||||
|
|
||||||
**File: `src/components/Settings/StartupPreferencesSettings.tsx`**
|
|
||||||
- Added UI toggle for "Auto-collapse sidebar on small screens"
|
|
||||||
|
|
||||||
## Accessibility Features
|
|
||||||
- Focus trap in mobile sidebar (Tab key navigation stays within drawer)
|
|
||||||
- ESC key closes mobile sidebar
|
|
||||||
- Backdrop click closes mobile sidebar
|
|
||||||
- Proper ARIA attributes (`aria-hidden`, `aria-expanded`, `aria-controls`)
|
|
||||||
- Touch target minimum size enforcement (44x44px)
|
|
||||||
- Focus restoration when closing sidebar
|
|
||||||
|
|
||||||
## Mobile Behaviors
|
|
||||||
1. **Sidebar**: Slides in from left as overlay drawer with backdrop
|
|
||||||
2. **Hamburger Menu**: Fixed position top-left when sidebar closed
|
|
||||||
3. **Selecting Content**: Auto-closes sidebar on mobile
|
|
||||||
4. **Opening Settings**: Auto-closes sidebar on mobile
|
|
||||||
5. **Highlights Panel**: Hidden on mobile (content takes full width)
|
|
||||||
6. **Modals**: Full-screen sheet style from bottom
|
|
||||||
7. **Toasts**: Bottom position with safe area padding
|
|
||||||
|
|
||||||
## Responsive Breakpoints
|
|
||||||
- **Mobile**: ≤768px (sidebar overlay, single column)
|
|
||||||
- **Tablet**: ≤1024px (defined but not actively used yet)
|
|
||||||
- **Desktop**: >768px (three-pane layout as before)
|
|
||||||
|
|
||||||
## Browser Support
|
|
||||||
- Modern browsers with CSS Grid support
|
|
||||||
- iOS Safari (including safe area insets)
|
|
||||||
- Chrome for Android
|
|
||||||
- Firefox Mobile
|
|
||||||
- Safari on iPadOS
|
|
||||||
|
|
||||||
## Safe Area Support
|
|
||||||
The app respects device safe areas (notches, home indicators) through CSS environment variables:
|
|
||||||
- `env(safe-area-inset-top)`
|
|
||||||
- `env(safe-area-inset-bottom)`
|
|
||||||
- `env(safe-area-inset-left)`
|
|
||||||
- `env(safe-area-inset-right)`
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
Potential improvements for future iterations:
|
|
||||||
- Swipe gesture to open/close sidebar
|
|
||||||
- Pull-to-refresh on mobile
|
|
||||||
- Bottom sheet for highlights panel on mobile
|
|
||||||
- Optimized font sizes for mobile reading
|
|
||||||
- Mobile-specific view mode (perhaps auto-switch to compact on mobile)
|
|
||||||
- Haptic feedback on interactions (iOS/Android)
|
|
||||||
- Share sheet integration
|
|
||||||
- Install prompt for PWA
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
- [x] Sidebar opens/closes on mobile
|
|
||||||
- [x] Hamburger button visible on mobile
|
|
||||||
- [x] Backdrop closes sidebar
|
|
||||||
- [x] ESC key closes sidebar
|
|
||||||
- [x] Focus trap works in sidebar
|
|
||||||
- [x] Selecting bookmark closes sidebar
|
|
||||||
- [x] No horizontal scroll
|
|
||||||
- [x] Touch targets ≥ 44px
|
|
||||||
- [x] Modals are full-screen on mobile
|
|
||||||
- [x] Toasts appear at bottom with safe area
|
|
||||||
- [x] Build completes without errors
|
|
||||||
- [ ] Test on actual iOS device (iPhone)
|
|
||||||
- [ ] Test on actual Android device
|
|
||||||
- [ ] Test with keyboard navigation
|
|
||||||
- [ ] Test with screen reader
|
|
||||||
- [ ] Test landscape orientation
|
|
||||||
- [ ] Test on various screen sizes (320px, 375px, 414px, 768px)
|
|
||||||
|
|
||||||
## Commit History
|
|
||||||
1. `feat: update viewport meta for mobile support`
|
|
||||||
2. `feat: add media query hooks for responsive design`
|
|
||||||
3. `feat: add mobile sidebar state management to useBookmarksUI`
|
|
||||||
4. `feat: add mobile-responsive CSS with breakpoints and safe areas`
|
|
||||||
5. `feat: implement mobile overlay sidebar with focus trap and ESC handling`
|
|
||||||
6. `feat: add mobile auto-collapse setting`
|
|
||||||
7. `fix: resolve TypeScript errors for mobile implementation`
|
|
||||||
|
|
||||||
188
TAILWIND_MIGRATION.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Tailwind CSS Migration Status
|
||||||
|
|
||||||
|
## ✅ Completed (Core Infrastructure)
|
||||||
|
|
||||||
|
### Phase 1: Setup & Foundation
|
||||||
|
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
|
||||||
|
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
|
||||||
|
- [x] Create `src/styles/tailwind.css` with base/components/utilities
|
||||||
|
- [x] Import Tailwind before existing CSS in `main.tsx`
|
||||||
|
- [x] Enable Tailwind preflight (CSS reset)
|
||||||
|
|
||||||
|
### Phase 2: Base Styles Reconciliation
|
||||||
|
- [x] Add CSS variables for user-settable theme colors
|
||||||
|
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
|
||||||
|
- `--reading-font`, `--reading-font-size`
|
||||||
|
- [x] Simplify `global.css` to work with Tailwind preflight
|
||||||
|
- [x] Remove redundant base styles handled by Tailwind
|
||||||
|
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
|
||||||
|
|
||||||
|
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
|
||||||
|
- [x] Switch from pane-scrolling to document-scrolling
|
||||||
|
- [x] Make sidebars sticky on desktop (`position: sticky`)
|
||||||
|
- [x] Update `app.css` to remove fixed container heights
|
||||||
|
- [x] Update `ThreePaneLayout.tsx` to use window scroll
|
||||||
|
- [x] Fix reading position tracking to work with document scroll
|
||||||
|
- [x] Maintain mobile overlay behavior
|
||||||
|
|
||||||
|
### Phase 4: Component Migrations
|
||||||
|
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
|
||||||
|
- Removed 80+ lines of CSS
|
||||||
|
- Added shimmer animation to Tailwind config
|
||||||
|
- Z-index layering maintained (1102)
|
||||||
|
|
||||||
|
- [x] **Mobile UI Elements**: Tailwind utilities
|
||||||
|
- Mobile hamburger button
|
||||||
|
- Mobile highlights button
|
||||||
|
- Mobile backdrop
|
||||||
|
- Removed 60+ lines of CSS
|
||||||
|
|
||||||
|
- [x] **App Container**: Tailwind utilities
|
||||||
|
- Responsive padding (p-0 md:p-4)
|
||||||
|
- Min-height viewport support
|
||||||
|
|
||||||
|
## 📊 Impact & Metrics
|
||||||
|
|
||||||
|
### Lines of CSS Removed
|
||||||
|
- `global.css`: ~50 lines removed
|
||||||
|
- `reader.css`: ~80 lines removed (progress indicator)
|
||||||
|
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
|
||||||
|
- `sidebar.css`: ~30 lines removed (mobile hamburger)
|
||||||
|
- **Total**: ~190 lines removed
|
||||||
|
|
||||||
|
### Key Achievements
|
||||||
|
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
|
||||||
|
2. **Tailwind Integration**: Fully functional with preflight enabled
|
||||||
|
3. **No Breaking Changes**: All existing functionality preserved
|
||||||
|
4. **Type Safety**: TypeScript checks passing
|
||||||
|
5. **Lint Clean**: ESLint checks passing
|
||||||
|
6. **Responsive**: Mobile/tablet/desktop layouts working
|
||||||
|
|
||||||
|
## 🔄 Remaining Work (Incremental)
|
||||||
|
|
||||||
|
The following migrations are **optional enhancements** that can be done as components are touched:
|
||||||
|
|
||||||
|
### High-Value Components
|
||||||
|
- [ ] **ContentPanel** - Large component, high impact
|
||||||
|
- Reader header, meta info, loading states
|
||||||
|
- Mark as read button
|
||||||
|
- Article/video menus
|
||||||
|
|
||||||
|
- [ ] **BookmarkList & BookmarkItem** - Core UI
|
||||||
|
- Card layouts (compact/cards/large views)
|
||||||
|
- Bookmark metadata display
|
||||||
|
- Interactive states
|
||||||
|
|
||||||
|
- [ ] **HighlightsPanel** - Feature-rich
|
||||||
|
- Header with toggles
|
||||||
|
- Highlight items
|
||||||
|
- Level-based styling
|
||||||
|
|
||||||
|
- [ ] **Settings Components** - Forms & controls
|
||||||
|
- Color pickers
|
||||||
|
- Font selectors
|
||||||
|
- Toggle switches
|
||||||
|
- Sliders
|
||||||
|
|
||||||
|
### CSS Files to Prune
|
||||||
|
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
|
||||||
|
- `src/styles/components/cards.css` - Bookmark card styles
|
||||||
|
- `src/styles/components/modals.css` - Modal dialogs
|
||||||
|
- `src/styles/layout/highlights.css` - Highlight panel layout
|
||||||
|
|
||||||
|
## 🎯 Migration Strategy
|
||||||
|
|
||||||
|
### For New Components
|
||||||
|
Use Tailwind utilities from the start. Reference:
|
||||||
|
```tsx
|
||||||
|
// Good: Tailwind utilities
|
||||||
|
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
|
||||||
|
|
||||||
|
// Avoid: New CSS classes
|
||||||
|
<div className="custom-component">
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Existing Components
|
||||||
|
Migrate incrementally when touching files:
|
||||||
|
1. Replace layout utilities (flex, grid, spacing, sizing)
|
||||||
|
2. Replace color/background utilities
|
||||||
|
3. Replace typography utilities
|
||||||
|
4. Replace responsive variants
|
||||||
|
5. Remove old CSS rules
|
||||||
|
6. Keep file under 210 lines
|
||||||
|
|
||||||
|
### CSS Variable Usage
|
||||||
|
Dynamic values should still use CSS variables or inline styles:
|
||||||
|
```tsx
|
||||||
|
// User-settable colors
|
||||||
|
style={{ backgroundColor: settings.highlightColorMine }}
|
||||||
|
|
||||||
|
// Or reference CSS variable
|
||||||
|
className="bg-[var(--highlight-color-mine)]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Technical Notes
|
||||||
|
|
||||||
|
### Z-Index Layering
|
||||||
|
- Mobile sidepanes: `z-[1001]`
|
||||||
|
- Mobile backdrop: `z-[999]`
|
||||||
|
- Progress indicator: `z-[1102]`
|
||||||
|
- Mobile buttons: `z-[900]`
|
||||||
|
- Relay status: `z-[999]`
|
||||||
|
- Modals: `z-[10000]`
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
- Mobile: `< 768px`
|
||||||
|
- Tablet: `768px - 1024px`
|
||||||
|
- Desktop: `> 1024px`
|
||||||
|
|
||||||
|
Use Tailwind: `md:` (768px), `lg:` (1024px)
|
||||||
|
|
||||||
|
### Safe Area Insets
|
||||||
|
Mobile notch support:
|
||||||
|
```tsx
|
||||||
|
style={{
|
||||||
|
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||||
|
left: 'calc(1rem + env(safe-area-inset-left))'
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Animations
|
||||||
|
Add to `tailwind.config.js`:
|
||||||
|
```js
|
||||||
|
keyframes: {
|
||||||
|
shimmer: {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(100%)' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Success Criteria Met
|
||||||
|
|
||||||
|
- [x] Tailwind CSS fully integrated and functional
|
||||||
|
- [x] Document scrolling working correctly
|
||||||
|
- [x] Reading position tracking accurate
|
||||||
|
- [x] Progress indicator always visible
|
||||||
|
- [x] No TypeScript errors
|
||||||
|
- [x] No linting errors
|
||||||
|
- [x] Mobile responsiveness maintained
|
||||||
|
- [x] Theme colors (user settings) working
|
||||||
|
- [x] All existing features functional
|
||||||
|
|
||||||
|
## 🚀 Next Steps
|
||||||
|
|
||||||
|
1. **Ship It**: Current state is production-ready
|
||||||
|
2. **Incremental Migration**: Convert components as you touch them
|
||||||
|
3. **Monitor**: Watch for any CSS conflicts
|
||||||
|
4. **Cleanup**: Eventually remove unused CSS files
|
||||||
|
5. **Document**: Update component docs with Tailwind patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: ✅ **CORE MIGRATION COMPLETE**
|
||||||
|
**Date**: 2025-01-14
|
||||||
|
**Commits**: 8 conventional commits
|
||||||
|
**Lines Removed**: ~190 lines of CSS
|
||||||
|
**Breaking Changes**: None
|
||||||
|
|
||||||
201
api/video-meta.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { getSubtitles } from '@treeee/youtube-caption-extractor'
|
||||||
|
|
||||||
|
type Caption = { start: number; dur: number; text: string }
|
||||||
|
|
||||||
|
type Subtitle = { start: string | number; dur: string | number; text: string }
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
body: unknown
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type VimeoOEmbedResponse = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
author_name: string
|
||||||
|
author_url: string
|
||||||
|
provider_name: string
|
||||||
|
provider_url: string
|
||||||
|
type: string
|
||||||
|
version: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
html: string
|
||||||
|
thumbnail_url: string
|
||||||
|
thumbnail_width: number
|
||||||
|
thumbnail_height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache for 7 days
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const memoryCache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
function buildKey(videoId: string, lang: string, preferAuto?: string | string[], source?: string) {
|
||||||
|
return `${source || 'video'}|${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(res: VercelResponse, data: unknown) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||||
|
return res.status(200).json(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bad(res: VercelResponse, code: number, message: string) {
|
||||||
|
return res.status(code).json({ error: message })
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVideoId(url: string): { id: string; source: 'youtube' | 'vimeo' } | null {
|
||||||
|
// YouTube patterns
|
||||||
|
const youtubePatterns = [
|
||||||
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||||
|
/youtube\.com\/v\/([^&\n?#]+)/
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of youtubePatterns) {
|
||||||
|
const match = url.match(pattern)
|
||||||
|
if (match) {
|
||||||
|
return { id: match[1], source: 'youtube' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vimeo patterns
|
||||||
|
const vimeoPatterns = [
|
||||||
|
/vimeo\.com\/(\d+)/,
|
||||||
|
/player\.vimeo\.com\/video\/(\d+)/
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of vimeoPatterns) {
|
||||||
|
const match = url.match(pattern)
|
||||||
|
if (match) {
|
||||||
|
return { id: match[1], source: 'vimeo' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
|
||||||
|
for (const lang of preferredLangs) {
|
||||||
|
try {
|
||||||
|
const caps = await getSubtitles({ videoID, lang })
|
||||||
|
if (Array.isArray(caps) && caps.length > 0) {
|
||||||
|
// Convert the returned subtitles to our Caption format
|
||||||
|
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
|
||||||
|
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
|
||||||
|
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
|
||||||
|
text: cap.text
|
||||||
|
}))
|
||||||
|
return { caps: convertedCaps, lang, isAuto: !manualFirst }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||||
|
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||||
|
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||||
|
|
||||||
|
const response = await fetch(oembedUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: VimeoOEmbedResponse = await response.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.title || '',
|
||||||
|
description: data.description || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const url = (req.query.url as string | undefined)?.trim()
|
||||||
|
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||||
|
|
||||||
|
if (!url && !videoId) {
|
||||||
|
return bad(res, 400, 'Missing url or videoId parameter')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract video info from URL or use provided videoId
|
||||||
|
let videoInfo: { id: string; source: 'youtube' | 'vimeo' }
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
const extracted = extractVideoId(url)
|
||||||
|
if (!extracted) {
|
||||||
|
return bad(res, 400, 'Unsupported video URL. Only YouTube and Vimeo are supported.')
|
||||||
|
}
|
||||||
|
videoInfo = extracted
|
||||||
|
} else {
|
||||||
|
// If only videoId is provided, assume YouTube for backward compatibility
|
||||||
|
videoInfo = { id: videoId!, source: 'youtube' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||||
|
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||||
|
const preferAuto = req.query.preferAuto === 'true'
|
||||||
|
|
||||||
|
const cacheKey = buildKey(videoInfo.id, lang, preferAuto ? 'auto' : undefined, videoInfo.source)
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(cacheKey)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
return ok(res, cached.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (videoInfo.source === 'youtube') {
|
||||||
|
// YouTube handling
|
||||||
|
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
|
||||||
|
const title = ''
|
||||||
|
const description = ''
|
||||||
|
|
||||||
|
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||||
|
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||||
|
|
||||||
|
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||||
|
// Manual first
|
||||||
|
selected = await pickCaptions(videoInfo.id, langs, true)
|
||||||
|
if (!selected) {
|
||||||
|
// Try auto
|
||||||
|
selected = await pickCaptions(videoInfo.id, langs, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const captions = selected?.caps || []
|
||||||
|
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||||
|
const response = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
captions,
|
||||||
|
transcript,
|
||||||
|
lang: selected?.lang || lang,
|
||||||
|
isAuto: selected?.isAuto || false,
|
||||||
|
source: 'youtube'
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||||
|
return ok(res, response)
|
||||||
|
} else if (videoInfo.source === 'vimeo') {
|
||||||
|
// Vimeo handling
|
||||||
|
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||||
|
transcript: '', // No transcript available
|
||||||
|
lang: 'en', // Default language
|
||||||
|
isAuto: false, // Not applicable for Vimeo
|
||||||
|
source: 'vimeo'
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||||
|
return ok(res, response)
|
||||||
|
} else {
|
||||||
|
return bad(res, 400, 'Unsupported video source')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return bad(res, 500, `Failed to fetch ${videoInfo.source} metadata`)
|
||||||
|
}
|
||||||
|
}
|
||||||
93
api/vimeo-meta.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
body: unknown
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type VimeoOEmbedResponse = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
author_name: string
|
||||||
|
author_url: string
|
||||||
|
provider_name: string
|
||||||
|
provider_url: string
|
||||||
|
type: string
|
||||||
|
version: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
html: string
|
||||||
|
thumbnail_url: string
|
||||||
|
thumbnail_width: number
|
||||||
|
thumbnail_height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache for 7 days
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const memoryCache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
function buildKey(videoId: string) {
|
||||||
|
return `vimeo|${videoId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(res: VercelResponse, data: unknown) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||||
|
return res.status(200).json(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bad(res: VercelResponse, code: number, message: string) {
|
||||||
|
return res.status(code).json({ error: message })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||||
|
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||||
|
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||||
|
|
||||||
|
const response = await fetch(oembedUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: VimeoOEmbedResponse = await response.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.title || '',
|
||||||
|
description: data.description || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||||
|
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||||
|
|
||||||
|
// Validate that videoId is a number
|
||||||
|
if (!/^\d+$/.test(videoId)) {
|
||||||
|
return bad(res, 400, 'Invalid Vimeo video ID - must be numeric')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = buildKey(videoId)
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(cacheKey)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
return ok(res, cached.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title, description } = await getVimeoMetadata(videoId)
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||||
|
transcript: '', // No transcript available
|
||||||
|
lang: 'en', // Default language
|
||||||
|
isAuto: false, // Not applicable for Vimeo
|
||||||
|
source: 'vimeo'
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||||
|
return ok(res, response)
|
||||||
|
} catch (e) {
|
||||||
|
return bad(res, 500, 'Failed to fetch Vimeo metadata')
|
||||||
|
}
|
||||||
|
}
|
||||||
101
api/youtube-meta.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { getSubtitles } from '@treeee/youtube-caption-extractor'
|
||||||
|
|
||||||
|
type Caption = { start: number; dur: number; text: string }
|
||||||
|
|
||||||
|
type Subtitle = { start: string | number; dur: string | number; text: string }
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
body: unknown
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache for 7 days
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const memoryCache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
function buildKey(videoId: string, lang: string, preferAuto?: string | string[]) {
|
||||||
|
return `${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(res: VercelResponse, data: unknown) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||||
|
return res.status(200).json(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bad(res: VercelResponse, code: number, message: string) {
|
||||||
|
return res.status(code).json({ error: message })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
|
||||||
|
for (const lang of preferredLangs) {
|
||||||
|
try {
|
||||||
|
const caps = await getSubtitles({ videoID, lang })
|
||||||
|
if (Array.isArray(caps) && caps.length > 0) {
|
||||||
|
// Convert the returned subtitles to our Caption format
|
||||||
|
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
|
||||||
|
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
|
||||||
|
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
|
||||||
|
text: cap.text
|
||||||
|
}))
|
||||||
|
return { caps: convertedCaps, lang, isAuto: !manualFirst }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||||
|
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||||
|
|
||||||
|
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||||
|
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||||
|
const preferAuto = req.query.preferAuto === 'true'
|
||||||
|
|
||||||
|
const cacheKey = buildKey(videoId, lang, preferAuto ? 'auto' : undefined)
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(cacheKey)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
return ok(res, cached.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Since getVideoDetails doesn't exist, we'll use a simple approach
|
||||||
|
// In a real implementation, you might want to use YouTube's API or other methods
|
||||||
|
const title = '' // Will be populated from captions or other sources
|
||||||
|
const description = ''
|
||||||
|
|
||||||
|
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||||
|
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||||
|
|
||||||
|
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||||
|
// Manual first
|
||||||
|
selected = await pickCaptions(videoId, langs, true)
|
||||||
|
if (!selected) {
|
||||||
|
// Try auto
|
||||||
|
selected = await pickCaptions(videoId, langs, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const captions = selected?.caps || []
|
||||||
|
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||||
|
const response = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
captions,
|
||||||
|
transcript,
|
||||||
|
lang: selected?.lang || lang,
|
||||||
|
isAuto: selected?.isAuto || false,
|
||||||
|
source: 'youtube'
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||||
|
return ok(res, response)
|
||||||
|
} catch (e) {
|
||||||
|
return bad(res, 500, 'Failed to fetch YouTube metadata')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
12
index.html
@@ -2,8 +2,13 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Boris - Nostr Bookmarks</title>
|
<title>Boris - Nostr Bookmarks</title>
|
||||||
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
<link rel="canonical" href="https://read.withboris.com/" />
|
<link rel="canonical" href="https://read.withboris.com/" />
|
||||||
@@ -20,6 +25,11 @@
|
|||||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
|
|
||||||
|
<!-- Default to system theme until settings load from Nostr -->
|
||||||
|
<script>
|
||||||
|
document.documentElement.className = 'theme-system';
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
6958
package-lock.json
generated
26
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.3.8",
|
"version": "0.6.14",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -12,8 +12,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
"applesauce-core": "^4.0.0",
|
"applesauce-core": "^4.0.0",
|
||||||
@@ -22,25 +25,38 @@
|
|||||||
"applesauce-react": "^4.0.0",
|
"applesauce-react": "^4.0.0",
|
||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"fast-average-color": "^9.5.0",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-loading-skeleton": "^3.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-player": "^2.16.0",
|
||||||
"react-router-dom": "^7.9.3",
|
"react-router-dom": "^7.9.3",
|
||||||
"reading-time-estimator": "^1.14.0",
|
"reading-time-estimator": "^1.14.0",
|
||||||
"remark-gfm": "^4.0.1"
|
"rehype-prism-plus": "^2.0.1",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"use-pull-to-refresh": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.0.8"
|
"vite": "^5.0.8",
|
||||||
|
"vite-plugin-pwa": "^1.0.3",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"root": true,
|
"root": true,
|
||||||
@@ -59,7 +75,8 @@
|
|||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@typescript-eslint",
|
"@typescript-eslint",
|
||||||
"react-refresh"
|
"react-refresh",
|
||||||
|
"react-hooks"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"react-refresh/only-export-components": [
|
"react-refresh/only-export-components": [
|
||||||
@@ -68,6 +85,7 @@
|
|||||||
"allowConstantExport": true
|
"allowConstantExport": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|||||||
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 564 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
37
public/manifest.webmanifest
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "Boris - Nostr Bookmarks",
|
||||||
|
"short_name": "Boris",
|
||||||
|
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#0f172a",
|
||||||
|
"background_color": "#0b1220",
|
||||||
|
"orientation": "any",
|
||||||
|
"categories": ["productivity", "social", "utilities"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
56
public/sw.js
@@ -1,56 +0,0 @@
|
|||||||
// Service Worker for Boris - handles offline image caching
|
|
||||||
const CACHE_NAME = 'boris-image-cache-v1'
|
|
||||||
|
|
||||||
// Install event - activate immediately
|
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
console.log('[SW] Installing service worker...')
|
|
||||||
self.skipWaiting()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Activate event - take control immediately
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
console.log('[SW] Activating service worker...')
|
|
||||||
event.waitUntil(self.clients.claim())
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch event - intercept image requests
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const url = new URL(event.request.url)
|
|
||||||
|
|
||||||
// Only intercept image requests
|
|
||||||
const isImage = event.request.destination === 'image' ||
|
|
||||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
|
||||||
|
|
||||||
if (!isImage) {
|
|
||||||
return // Let other requests pass through
|
|
||||||
}
|
|
||||||
|
|
||||||
event.respondWith(
|
|
||||||
caches.open(CACHE_NAME).then(cache => {
|
|
||||||
return cache.match(event.request).then(cachedResponse => {
|
|
||||||
if (cachedResponse) {
|
|
||||||
console.log('[SW] Serving cached image:', url.pathname)
|
|
||||||
return cachedResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not in cache, try to fetch
|
|
||||||
return fetch(event.request)
|
|
||||||
.then(response => {
|
|
||||||
// Only cache successful responses
|
|
||||||
if (response && response.status === 200) {
|
|
||||||
// Clone the response before caching
|
|
||||||
cache.put(event.request, response.clone())
|
|
||||||
console.log('[SW] Cached new image:', url.pathname)
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('[SW] Fetch failed for:', url.pathname, error)
|
|
||||||
// Return a fallback or let it fail
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
1
public/thank-you.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
142
src/App.tsx
@@ -11,7 +11,9 @@ import { createAddressLoader } from 'applesauce-loaders/loaders'
|
|||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
|
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -60,6 +62,15 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/support"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/explore"
|
path="/explore"
|
||||||
element={
|
element={
|
||||||
@@ -69,6 +80,73 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/explore/writings"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me"
|
||||||
|
element={<Navigate to="/me/highlights" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/highlights"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/reading-list"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/archive"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/writings"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/p/:npub"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/p/:npub/writings"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
@@ -79,6 +157,7 @@ function App() {
|
|||||||
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
||||||
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
||||||
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
||||||
|
const isOnline = useOnlineStatus()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeApp = async () => {
|
const initializeApp = async () => {
|
||||||
@@ -136,8 +215,7 @@ function App() {
|
|||||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
||||||
|
|
||||||
// Store subscription for cleanup
|
// Store subscription for cleanup
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
||||||
;(pool as any)._keepAliveSubscription = keepAliveSub
|
|
||||||
|
|
||||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||||
const addressLoader = createAddressLoader(pool, {
|
const addressLoader = createAddressLoader(pool, {
|
||||||
@@ -156,10 +234,9 @@ function App() {
|
|||||||
accountsSub.unsubscribe()
|
accountsSub.unsubscribe()
|
||||||
activeSub.unsubscribe()
|
activeSub.unsubscribe()
|
||||||
// Clean up keep-alive subscription if it exists
|
// Clean up keep-alive subscription if it exists
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
if ((pool as any)._keepAliveSubscription) {
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||||
(pool as any)._keepAliveSubscription.unsubscribe()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +251,25 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Monitor online/offline status
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOnline) {
|
||||||
|
showToast('You are offline. Some features may be limited.')
|
||||||
|
}
|
||||||
|
}, [isOnline, showToast])
|
||||||
|
|
||||||
|
// Listen for service worker updates
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSWUpdate = () => {
|
||||||
|
showToast('New version available! Refresh to update.')
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('sw-update-available', handleSWUpdate)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('sw-update-available', handleSWUpdate)
|
||||||
|
}
|
||||||
|
}, [showToast])
|
||||||
|
|
||||||
if (!eventStore || !accountManager || !relayPool) {
|
if (!eventStore || !accountManager || !relayPool) {
|
||||||
return (
|
return (
|
||||||
<div className="loading">
|
<div className="loading">
|
||||||
@@ -183,22 +279,24 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventStoreProvider eventStore={eventStore}>
|
<SkeletonThemeProvider>
|
||||||
<AccountsProvider manager={accountManager}>
|
<EventStoreProvider eventStore={eventStore}>
|
||||||
<BrowserRouter>
|
<AccountsProvider manager={accountManager}>
|
||||||
<div className="app">
|
<BrowserRouter>
|
||||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||||
</div>
|
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||||
</BrowserRouter>
|
</div>
|
||||||
{toastMessage && (
|
</BrowserRouter>
|
||||||
<Toast
|
{toastMessage && (
|
||||||
message={toastMessage}
|
<Toast
|
||||||
type={toastType}
|
message={toastMessage}
|
||||||
onClose={clearToast}
|
type={toastType}
|
||||||
/>
|
onClose={clearToast}
|
||||||
)}
|
/>
|
||||||
</AccountsProvider>
|
)}
|
||||||
</EventStoreProvider>
|
</AccountsProvider>
|
||||||
|
</EventStoreProvider>
|
||||||
|
</SkeletonThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
@@ -139,7 +140,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
clearTimeout(fetchTimeoutRef.current)
|
clearTimeout(fetchTimeoutRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [url]) // Only depend on url
|
}, [url, title, description, tagsInput])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -182,7 +183,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
@@ -279,7 +280,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
58
src/components/AuthorCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
interface AuthorCardProps {
|
||||||
|
authorPubkey: string
|
||||||
|
clickable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
|
||||||
|
|
||||||
|
const getAuthorName = () => {
|
||||||
|
if (profile?.name) return profile.name
|
||||||
|
if (profile?.display_name) return profile.display_name
|
||||||
|
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorImage = profile?.picture || profile?.image
|
||||||
|
const authorBio = profile?.about
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (clickable) {
|
||||||
|
const npub = nip19.npubEncode(authorPubkey)
|
||||||
|
navigate(`/p/${npub}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`author-card ${clickable ? 'author-card-clickable' : ''}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={clickable ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
<div className="author-card-avatar">
|
||||||
|
{authorImage ? (
|
||||||
|
<img src={authorImage} alt={getAuthorName()} />
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon icon={faUserCircle} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="author-card-content">
|
||||||
|
<div className="author-card-name">{getAuthorName()}</div>
|
||||||
|
{authorBio && (
|
||||||
|
<p className="author-card-bio">{authorBio}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthorCard
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
|
import { faCalendar, faUser, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { formatDistance } from 'date-fns'
|
import { formatDistance } from 'date-fns'
|
||||||
import { BlogPostPreview } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
@@ -10,9 +10,10 @@ import { Models } from 'applesauce-core'
|
|||||||
interface BlogPostCardProps {
|
interface BlogPostCardProps {
|
||||||
post: BlogPostPreview
|
post: BlogPostPreview
|
||||||
href: string
|
href: string
|
||||||
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
const displayName = profile?.name || profile?.display_name ||
|
const displayName = profile?.name || profile?.display_name ||
|
||||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||||
@@ -25,18 +26,22 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
className="blog-post-card"
|
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
>
|
>
|
||||||
{post.image && (
|
<div className="blog-post-card-image">
|
||||||
<div className="blog-post-card-image">
|
{post.image ? (
|
||||||
<img
|
<img
|
||||||
src={post.image}
|
src={post.image}
|
||||||
alt={post.title}
|
alt={post.title}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="blog-post-image-placeholder">
|
||||||
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="blog-post-card-content">
|
<div className="blog-post-card-content">
|
||||||
<h3 className="blog-post-card-title">{post.title}</h3>
|
<h3 className="blog-post-card-title">{post.title}</h3>
|
||||||
{post.summary && (
|
{post.summary && (
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
|
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
||||||
@@ -11,17 +13,15 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
|||||||
import { CompactView } from './BookmarkViews/CompactView'
|
import { CompactView } from './BookmarkViews/CompactView'
|
||||||
import { LargeView } from './BookmarkViews/LargeView'
|
import { LargeView } from './BookmarkViews/LargeView'
|
||||||
import { CardView } from './BookmarkViews/CardView'
|
import { CardView } from './BookmarkViews/CardView'
|
||||||
import { UserSettings } from '../services/settingsService'
|
|
||||||
|
|
||||||
interface BookmarkItemProps {
|
interface BookmarkItemProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
index: number
|
index: number
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
viewMode?: ViewMode
|
viewMode?: ViewMode
|
||||||
settings?: UserSettings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
|
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
|
|
||||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||||
@@ -68,18 +68,40 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
return short(bookmark.pubkey) // fallback to short pubkey
|
return short(bookmark.pubkey) // fallback to short pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
// use helper from kindIcon.ts
|
// Get content type icon based on bookmark kind and URL classification
|
||||||
|
const getContentTypeIcon = (): IconDefinition => {
|
||||||
|
if (isArticle) return faNewspaper
|
||||||
|
|
||||||
|
// For web bookmarks, classify the URL to determine icon
|
||||||
|
if (isWebBookmark && firstUrlClassification) {
|
||||||
|
switch (firstUrlClassification.type) {
|
||||||
|
case 'youtube':
|
||||||
|
case 'video':
|
||||||
|
return faCirclePlay
|
||||||
|
case 'image':
|
||||||
|
return faCamera
|
||||||
|
case 'article':
|
||||||
|
return faNewspaper
|
||||||
|
default:
|
||||||
|
return faGlobe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUrls) return faStickyNote // Just a text note
|
||||||
|
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
|
||||||
|
return faFileLines
|
||||||
|
}
|
||||||
|
|
||||||
const getIconForUrlType = (url: string) => {
|
const getIconForUrlType = (url: string) => {
|
||||||
const classification = classifyUrl(url)
|
const classification = classifyUrl(url)
|
||||||
switch (classification.type) {
|
switch (classification.type) {
|
||||||
case 'youtube':
|
case 'youtube':
|
||||||
case 'video':
|
case 'video':
|
||||||
return faPlay
|
return faCirclePlay
|
||||||
case 'image':
|
case 'image':
|
||||||
return faEye
|
return faCamera
|
||||||
default:
|
default:
|
||||||
return faBookOpen
|
return faFileLines
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,24 +132,23 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
getIconForUrlType,
|
|
||||||
firstUrlClassification,
|
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
eventNevent,
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
settings
|
contentTypeIcon: getContentTypeIcon()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'compact') {
|
if (viewMode === 'compact') {
|
||||||
return <CompactView {...sharedProps} />
|
const { articleImage: _articleImage, ...compactProps } = sharedProps
|
||||||
|
return <CompactView {...compactProps} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'large') {
|
if (viewMode === 'large') {
|
||||||
const previewImage = articleImage || instantPreview || ogImage
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
return <LargeView {...sharedProps} previewImage={previewImage} />
|
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import React from 'react'
|
import React, { useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
import SidebarHeader from './SidebarHeader'
|
import SidebarHeader from './SidebarHeader'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
|
import CompactButton from './CompactButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
|
import { BookmarkSkeleton } from './Skeletons'
|
||||||
|
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import AddBookmarkModal from './AddBookmarkModal'
|
||||||
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -26,8 +35,8 @@ interface BookmarkListProps {
|
|||||||
lastFetchTime?: number | null
|
lastFetchTime?: number | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
settings?: UserSettings
|
|
||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||||
@@ -45,39 +54,60 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
lastFetchTime,
|
lastFetchTime,
|
||||||
loading = false,
|
loading = false,
|
||||||
relayPool,
|
relayPool,
|
||||||
settings,
|
isMobile = false,
|
||||||
isMobile = false
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
// Helper to check if a bookmark has either content or a URL
|
const navigate = useNavigate()
|
||||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||||
// Check if has content (text)
|
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||||
const hasContent = ib.content && ib.content.trim().length > 0
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
// Check if has URL
|
|
||||||
let hasUrl = false
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
|
if (!activeAccount || !relayPool) {
|
||||||
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
throw new Error('Please login to create bookmarks')
|
||||||
if (ib.kind === 39701) {
|
|
||||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
|
||||||
hasUrl = !!dTag && dTag.trim().length > 0
|
|
||||||
} else {
|
|
||||||
// For other bookmarks, extract URLs from content
|
|
||||||
const urls = extractUrlsFromContent(ib.content || '')
|
|
||||||
hasUrl = urls.length > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show articles (kind:30023) as they have special handling
|
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||||
if (ib.kind === 30023) return true
|
|
||||||
|
|
||||||
// Otherwise, must have either content or URL
|
|
||||||
return hasContent || hasUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull-to-refresh for bookmarks
|
||||||
|
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !onRefresh
|
||||||
|
})
|
||||||
|
|
||||||
// Merge and flatten all individual bookmarks from all lists
|
// Merge and flatten all individual bookmarks from all lists
|
||||||
// Re-sort after flattening to ensure newest first across all lists
|
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(hasContentOrUrl)
|
.filter(hasContent)
|
||||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
|
||||||
|
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||||
|
const bookmarksWithoutSet = getBookmarksWithoutSet(allIndividualBookmarks)
|
||||||
|
const bookmarkSets = getBookmarkSets(allIndividualBookmarks)
|
||||||
|
|
||||||
|
// Group non-set bookmarks as before
|
||||||
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||||
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
|
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems },
|
||||||
|
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems },
|
||||||
|
{ key: 'web', title: 'Web bookmarks', items: groups.web },
|
||||||
|
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add bookmark sets as additional sections
|
||||||
|
bookmarkSets.forEach(set => {
|
||||||
|
sections.push({
|
||||||
|
key: `set-${set.name}`,
|
||||||
|
title: set.title || set.name,
|
||||||
|
items: set.bookmarks
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
// Check if the selected URL is in bookmarks
|
// Check if the selected URL is in bookmarks
|
||||||
@@ -107,86 +137,115 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
relayPool={relayPool}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{allIndividualBookmarks.length === 0 ? (
|
||||||
<div className="loading">
|
loading ? (
|
||||||
<FontAwesomeIcon icon={faSpinner} spin />
|
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||||
</div>
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
) : allIndividualBookmarks.length === 0 ? (
|
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
|
||||||
<div className="empty-state">
|
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||||
<p>No bookmarks found.</p>
|
))}
|
||||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bookmarks-list">
|
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
|
||||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
|
||||||
<BookmarkItem
|
|
||||||
key={`${individualBookmark.id}-${index}`}
|
|
||||||
bookmark={individualBookmark}
|
|
||||||
index={index}
|
|
||||||
onSelectUrl={onSelectUrl}
|
|
||||||
viewMode={viewMode}
|
|
||||||
settings={settings}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{onRefresh && (
|
|
||||||
<div className="refresh-section" style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '0.5rem',
|
|
||||||
padding: '1rem',
|
|
||||||
marginTop: '1rem',
|
|
||||||
borderTop: '1px solid var(--border-color)',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
color: 'var(--text-secondary)'
|
|
||||||
}}>
|
|
||||||
<IconButton
|
|
||||||
icon={faRotate}
|
|
||||||
onClick={onRefresh}
|
|
||||||
title="Refresh bookmarks"
|
|
||||||
ariaLabel="Refresh bookmarks"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
spin={isRefreshing}
|
|
||||||
/>
|
|
||||||
{lastFetchTime && (
|
|
||||||
<span>
|
|
||||||
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>No bookmarks found.</p>
|
||||||
|
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||||
|
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={bookmarksListRef}
|
||||||
|
className="bookmarks-list"
|
||||||
|
>
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isPulling || isRefreshing || false}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
|
{sections.filter(s => s.items.length > 0).map(section => (
|
||||||
|
<div key={section.key} className="bookmarks-section">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
||||||
|
{section.key === 'web' && activeAccount && (
|
||||||
|
<CompactButton
|
||||||
|
icon={faPlus}
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
title="Add web bookmark"
|
||||||
|
ariaLabel="Add web bookmark"
|
||||||
|
className="bookmark-section-action"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
|
{section.items.map((individualBookmark, index) => (
|
||||||
|
<BookmarkItem
|
||||||
|
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||||
|
bookmark={individualBookmark}
|
||||||
|
index={index}
|
||||||
|
onSelectUrl={onSelectUrl}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="view-mode-controls">
|
<div className="view-mode-controls">
|
||||||
<IconButton
|
<div className="view-mode-left">
|
||||||
icon={faList}
|
<IconButton
|
||||||
onClick={() => onViewModeChange('compact')}
|
icon={faHeart}
|
||||||
title="Compact list view"
|
onClick={() => navigate('/support')}
|
||||||
ariaLabel="Compact list view"
|
title="Support Boris"
|
||||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
ariaLabel="Support"
|
||||||
/>
|
variant="ghost"
|
||||||
<IconButton
|
style={{ color: friendsColor }}
|
||||||
icon={faThLarge}
|
/>
|
||||||
onClick={() => onViewModeChange('cards')}
|
</div>
|
||||||
title="Cards view"
|
<div className="view-mode-right">
|
||||||
ariaLabel="Cards view"
|
{onRefresh && (
|
||||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
<IconButton
|
||||||
/>
|
icon={faRotate}
|
||||||
<IconButton
|
onClick={onRefresh}
|
||||||
icon={faImage}
|
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||||
onClick={() => onViewModeChange('large')}
|
ariaLabel="Refresh bookmarks"
|
||||||
title="Large preview view"
|
variant="ghost"
|
||||||
ariaLabel="Large preview view"
|
disabled={isRefreshing}
|
||||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
spin={isRefreshing}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
icon={faList}
|
||||||
|
onClick={() => onViewModeChange('compact')}
|
||||||
|
title="Compact list view"
|
||||||
|
ariaLabel="Compact list view"
|
||||||
|
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faThLarge}
|
||||||
|
onClick={() => onViewModeChange('cards')}
|
||||||
|
title="Cards view"
|
||||||
|
ariaLabel="Cards view"
|
||||||
|
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faImage}
|
||||||
|
onClick={() => onViewModeChange('large')}
|
||||||
|
title="Large preview view"
|
||||||
|
ariaLabel="Large preview view"
|
||||||
|
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showAddModal && (
|
||||||
|
<AddBookmarkModal
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSave={handleSaveBookmark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
import { faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
import IconButton from '../IconButton'
|
|
||||||
import { classifyUrl } from '../../utils/helpers'
|
import { classifyUrl } from '../../utils/helpers'
|
||||||
import { IconGetter } from './shared'
|
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||||
|
import { getEventUrl } from '../../config/nostrGateways'
|
||||||
|
|
||||||
interface CardViewProps {
|
interface CardViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -16,15 +17,13 @@ interface CardViewProps {
|
|||||||
hasUrls: boolean
|
hasUrls: boolean
|
||||||
extractedUrls: string[]
|
extractedUrls: string[]
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
getIconForUrlType: IconGetter
|
|
||||||
firstUrlClassification: { buttonText: string } | null
|
|
||||||
authorNpub: string
|
authorNpub: string
|
||||||
eventNevent?: string
|
eventNevent?: string
|
||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleImage?: string
|
articleImage?: string
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
settings?: UserSettings
|
contentTypeIcon: IconDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardView: React.FC<CardViewProps> = ({
|
export const CardView: React.FC<CardViewProps> = ({
|
||||||
@@ -33,27 +32,56 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
getIconForUrlType,
|
|
||||||
firstUrlClassification,
|
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
eventNevent,
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
settings
|
contentTypeIcon
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(articleImage, settings)
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
|
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||||
|
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
||||||
|
|
||||||
|
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||||
|
|
||||||
const contentLength = (bookmark.content || '').length
|
const contentLength = (bookmark.content || '').length
|
||||||
const shouldTruncate = !expanded && contentLength > 210
|
const shouldTruncate = !expanded && contentLength > 210
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
|
||||||
|
// Determine which image to use (article image, instant preview, or OG image)
|
||||||
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
|
|
||||||
|
// Fetch OG image if we don't have any other image
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (firstUrl && !articleImage && !instantPreview && !ogImage) {
|
||||||
|
fetchOgImage(firstUrl).then(setOgImage)
|
||||||
|
}
|
||||||
|
}, [firstUrl, articleImage, instantPreview, ogImage])
|
||||||
|
|
||||||
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
|
|
||||||
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
triggerOpen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div
|
||||||
{isArticle && cachedImage && (
|
key={`${bookmark.id}-${index}`}
|
||||||
|
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||||
|
onClick={triggerOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{cachedImage && (
|
||||||
<div
|
<div
|
||||||
className="article-hero-image"
|
className="article-hero-image"
|
||||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||||
@@ -62,28 +90,20 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
<div className="bookmark-header">
|
<div className="bookmark-header">
|
||||||
<span className="bookmark-type">
|
<span className="bookmark-type">
|
||||||
{isWebBookmark ? (
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
<span className="fa-layers fa-fw">
|
{bookmark.isPrivate && (
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
|
||||||
</span>
|
|
||||||
) : bookmark.isPrivate ? (
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{eventNevent ? (
|
{eventNevent ? (
|
||||||
<a
|
<a
|
||||||
href={`https://search.dergigi.com/e/${eventNevent}`}
|
href={getEventUrl(eventNevent)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="bookmark-date-link"
|
className="bookmark-date-link"
|
||||||
title="Open event in search"
|
title="Open event in search"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{formatDate(bookmark.created_at)}
|
{formatDate(bookmark.created_at)}
|
||||||
</a>
|
</a>
|
||||||
@@ -95,31 +115,21 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
{extractedUrls.length > 0 && (
|
{extractedUrls.length > 0 && (
|
||||||
<div className="bookmark-urls">
|
<div className="bookmark-urls">
|
||||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||||
const classification = classifyUrl(url)
|
|
||||||
return (
|
return (
|
||||||
<div key={urlIndex} className="url-row">
|
<button
|
||||||
<button
|
key={urlIndex}
|
||||||
className="bookmark-url"
|
className="bookmark-url"
|
||||||
onClick={() => onSelectUrl?.(url)}
|
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||||
title="Open in reader"
|
title="Open in reader"
|
||||||
>
|
>
|
||||||
{url}
|
{url}
|
||||||
</button>
|
</button>
|
||||||
<IconButton
|
|
||||||
icon={getIconForUrlType(url)}
|
|
||||||
ariaLabel={classification.buttonText}
|
|
||||||
title={classification.buttonText}
|
|
||||||
variant="success"
|
|
||||||
size={32}
|
|
||||||
onClick={(e) => { e.preventDefault(); onSelectUrl?.(url) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{extractedUrls.length > 1 && (
|
{extractedUrls.length > 1 && (
|
||||||
<button
|
<button
|
||||||
className="expand-toggle-urls"
|
className="expand-toggle-urls"
|
||||||
onClick={() => setUrlsExpanded(v => !v)}
|
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
||||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||||
>
|
>
|
||||||
@@ -148,7 +158,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
{contentLength > 210 && (
|
{contentLength > 210 && (
|
||||||
<button
|
<button
|
||||||
className="expand-toggle"
|
className="expand-toggle"
|
||||||
onClick={() => setExpanded(v => !v)}
|
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||||
title={expanded ? 'Collapse' : 'Expand'}
|
title={expanded ? 'Collapse' : 'Expand'}
|
||||||
>
|
>
|
||||||
@@ -158,21 +168,16 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
|
|
||||||
<div className="bookmark-footer">
|
<div className="bookmark-footer">
|
||||||
<div className="bookmark-meta-minimal">
|
<div className="bookmark-meta-minimal">
|
||||||
<a
|
<Link
|
||||||
href={`https://search.dergigi.com/p/${authorNpub}`}
|
to={`/p/${authorNpub}`}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="author-link-minimal"
|
className="author-link-minimal"
|
||||||
title="Open author in search"
|
title="Open author profile"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{getAuthorDisplayName()}
|
{getAuthorDisplayName()}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{(hasUrls && firstUrlClassification) || bookmark.kind === 30023 ? (
|
{/* CTA removed */}
|
||||||
<button className="read-now-button-minimal" onClick={handleReadNow}>
|
|
||||||
{bookmark.kind === 30023 ? 'Read Article' : firstUrlClassification?.buttonText}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
import { IconGetter } from './shared'
|
|
||||||
|
|
||||||
interface CompactViewProps {
|
interface CompactViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -12,10 +12,8 @@ interface CompactViewProps {
|
|||||||
hasUrls: boolean
|
hasUrls: boolean
|
||||||
extractedUrls: string[]
|
extractedUrls: string[]
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
getIconForUrlType: IconGetter
|
|
||||||
firstUrlClassification: { buttonText: string } | null
|
|
||||||
articleImage?: string
|
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
|
contentTypeIcon: IconDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CompactView: React.FC<CompactViewProps> = ({
|
export const CompactView: React.FC<CompactViewProps> = ({
|
||||||
@@ -24,9 +22,8 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
getIconForUrlType,
|
articleSummary,
|
||||||
firstUrlClassification,
|
contentTypeIcon
|
||||||
articleSummary
|
|
||||||
}) => {
|
}) => {
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
@@ -56,18 +53,9 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
tabIndex={isClickable ? 0 : undefined}
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
{isWebBookmark ? (
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
<span className="fa-layers fa-fw">
|
{bookmark.isPrivate && (
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
|
||||||
</span>
|
|
||||||
) : bookmark.isPrivate ? (
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{displayText && (
|
{displayText && (
|
||||||
@@ -76,22 +64,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||||
{isClickable && (
|
{/* CTA removed */}
|
||||||
<button
|
|
||||||
className="compact-read-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (isArticle) {
|
|
||||||
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
|
||||||
} else {
|
|
||||||
onSelectUrl?.(extractedUrls[0])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDate } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { getEventUrl } from '../../config/nostrGateways'
|
||||||
|
|
||||||
interface LargeViewProps {
|
interface LargeViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -14,14 +17,13 @@ interface LargeViewProps {
|
|||||||
extractedUrls: string[]
|
extractedUrls: string[]
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
getIconForUrlType: IconGetter
|
getIconForUrlType: IconGetter
|
||||||
firstUrlClassification: { buttonText: string } | null
|
|
||||||
previewImage: string | null
|
previewImage: string | null
|
||||||
authorNpub: string
|
authorNpub: string
|
||||||
eventNevent?: string
|
eventNevent?: string
|
||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
settings?: UserSettings
|
contentTypeIcon: IconDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -31,24 +33,39 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
getIconForUrlType,
|
getIconForUrlType,
|
||||||
firstUrlClassification,
|
|
||||||
previewImage,
|
previewImage,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
eventNevent,
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
settings
|
contentTypeIcon
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
triggerOpen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div
|
||||||
|
key={`${bookmark.id}-${index}`}
|
||||||
|
className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||||
|
onClick={triggerOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
{(hasUrls || (isArticle && cachedImage)) && (
|
{(hasUrls || (isArticle && cachedImage)) && (
|
||||||
<div
|
<div
|
||||||
className="large-preview-image"
|
className="large-preview-image"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
if (isArticle) {
|
if (isArticle) {
|
||||||
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
} else {
|
} else {
|
||||||
@@ -77,34 +94,35 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="large-footer">
|
<div className="large-footer">
|
||||||
|
<span className="bookmark-type-large">
|
||||||
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
|
{bookmark.isPrivate && (
|
||||||
|
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="large-author">
|
<span className="large-author">
|
||||||
<a
|
<Link
|
||||||
href={`https://search.dergigi.com/p/${authorNpub}`}
|
to={`/p/${authorNpub}`}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="author-link-minimal"
|
className="author-link-minimal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{getAuthorDisplayName()}
|
{getAuthorDisplayName()}
|
||||||
</a>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{eventNevent && (
|
{eventNevent && (
|
||||||
<a
|
<a
|
||||||
href={`https://search.dergigi.com/e/${eventNevent}`}
|
href={getEventUrl(eventNevent)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="bookmark-date-link"
|
className="bookmark-date-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{formatDate(bookmark.created_at)}
|
{formatDate(bookmark.created_at)}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasUrls && firstUrlClassification) || isArticle ? (
|
{/* CTA removed */}
|
||||||
<button className="large-read-button" onClick={handleReadNow}>
|
|
||||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
|
||||||
{isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
|||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventStore } from 'applesauce-react/hooks'
|
import { useEventStore } from 'applesauce-react/hooks'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useSettings } from '../hooks/useSettings'
|
import { useSettings } from '../hooks/useSettings'
|
||||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||||
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||||
@@ -14,6 +15,8 @@ import { useRelayStatus } from '../hooks/useRelayStatus'
|
|||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
|
import Me from './Me'
|
||||||
|
import Support from './Support'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
|
||||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||||
@@ -24,24 +27,58 @@ interface BookmarksProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||||
const { naddr } = useParams<{ naddr?: string }>()
|
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const previousLocationRef = useRef<string>()
|
const previousLocationRef = useRef<string>()
|
||||||
|
|
||||||
|
// Check for highlight navigation state
|
||||||
|
const navigationState = location.state as { highlightId?: string; openHighlights?: boolean } | null
|
||||||
|
|
||||||
const externalUrl = location.pathname.startsWith('/r/')
|
const externalUrl = location.pathname.startsWith('/r/')
|
||||||
? decodeURIComponent(location.pathname.slice(3))
|
? decodeURIComponent(location.pathname.slice(3))
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const showSettings = location.pathname === '/settings'
|
const showSettings = location.pathname === '/settings'
|
||||||
const showExplore = location.pathname === '/explore'
|
const showExplore = location.pathname.startsWith('/explore')
|
||||||
|
const showMe = location.pathname.startsWith('/me')
|
||||||
|
const showProfile = location.pathname.startsWith('/p/')
|
||||||
|
const showSupport = location.pathname === '/support'
|
||||||
|
|
||||||
// Track previous location for going back from settings
|
// Extract tab from explore routes
|
||||||
|
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
|
// Extract tab from me routes
|
||||||
|
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||||
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
|
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||||
|
location.pathname === '/me/archive' ? 'archive' :
|
||||||
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
|
// Extract tab from profile routes
|
||||||
|
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||||
|
|
||||||
|
// Decode npub or nprofile to pubkey for profile view
|
||||||
|
let profilePubkey: string | undefined
|
||||||
|
if (npub && showProfile) {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(npub)
|
||||||
|
if (decoded.type === 'npub') {
|
||||||
|
profilePubkey = decoded.data
|
||||||
|
} else if (decoded.type === 'nprofile') {
|
||||||
|
profilePubkey = decoded.data.pubkey
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to decode npub/nprofile:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track previous location for going back from settings/me/explore/profile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSettings) {
|
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
||||||
previousLocationRef.current = location.pathname
|
previousLocationRef.current = location.pathname
|
||||||
}
|
}
|
||||||
}, [location.pathname, showSettings])
|
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
||||||
|
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
@@ -90,6 +127,29 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setHighlightVisibility
|
setHighlightVisibility
|
||||||
} = useBookmarksUI({ settings })
|
} = useBookmarksUI({ settings })
|
||||||
|
|
||||||
|
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
|
||||||
|
const prevPathnameRef = useRef<string>(location.pathname)
|
||||||
|
useEffect(() => {
|
||||||
|
// Only close if pathname actually changed, not on initial render or other state changes
|
||||||
|
if (isMobile && isSidebarOpen && prevPathnameRef.current !== location.pathname) {
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
prevPathnameRef.current = location.pathname
|
||||||
|
}, [location.pathname, isMobile, isSidebarOpen, toggleSidebar])
|
||||||
|
|
||||||
|
// Handle highlight navigation from explore page
|
||||||
|
useEffect(() => {
|
||||||
|
if (navigationState?.highlightId && navigationState?.openHighlights) {
|
||||||
|
// Open the highlights sidebar
|
||||||
|
setIsHighlightsCollapsed(false)
|
||||||
|
// Select the highlight (scroll happens automatically in useHighlightInteractions)
|
||||||
|
setSelectedHighlightId(navigationState.highlightId)
|
||||||
|
|
||||||
|
// Clear the state after handling to avoid re-triggering
|
||||||
|
navigate(location.pathname, { replace: true, state: {} })
|
||||||
|
}
|
||||||
|
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
bookmarksLoading,
|
bookmarksLoading,
|
||||||
@@ -194,6 +254,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
isSidebarOpen={isSidebarOpen}
|
isSidebarOpen={isSidebarOpen}
|
||||||
showSettings={showSettings}
|
showSettings={showSettings}
|
||||||
showExplore={showExplore}
|
showExplore={showExplore}
|
||||||
|
showMe={showMe}
|
||||||
|
showProfile={showProfile}
|
||||||
|
showSupport={showSupport}
|
||||||
bookmarks={bookmarks}
|
bookmarks={bookmarks}
|
||||||
bookmarksLoading={bookmarksLoading}
|
bookmarksLoading={bookmarksLoading}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -236,6 +299,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
onClearSelection={handleClearSelection}
|
onClearSelection={handleClearSelection}
|
||||||
currentUserPubkey={activeAccount?.pubkey}
|
currentUserPubkey={activeAccount?.pubkey}
|
||||||
followedPubkeys={followedPubkeys}
|
followedPubkeys={followedPubkeys}
|
||||||
|
activeAccount={activeAccount}
|
||||||
|
currentArticle={currentArticle}
|
||||||
highlights={highlights}
|
highlights={highlights}
|
||||||
highlightsLoading={highlightsLoading}
|
highlightsLoading={highlightsLoading}
|
||||||
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
||||||
@@ -247,7 +312,16 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
onCreateHighlight={handleCreateHighlight}
|
onCreateHighlight={handleCreateHighlight}
|
||||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||||
explore={showExplore ? (
|
explore={showExplore ? (
|
||||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||||
|
) : undefined}
|
||||||
|
me={showMe ? (
|
||||||
|
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||||
|
) : undefined}
|
||||||
|
profile={showProfile && profilePubkey ? (
|
||||||
|
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
||||||
|
) : undefined}
|
||||||
|
support={showSupport ? (
|
||||||
|
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
toastMessage={toastMessage ?? undefined}
|
toastMessage={toastMessage ?? undefined}
|
||||||
toastType={toastType}
|
toastType={toastType}
|
||||||
|
|||||||
41
src/components/CompactButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
|
||||||
|
interface CompactButtonProps {
|
||||||
|
icon?: IconDefinition
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
|
title?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
disabled?: boolean
|
||||||
|
spin?: boolean
|
||||||
|
className?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompactButton: React.FC<CompactButtonProps> = ({
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
ariaLabel,
|
||||||
|
disabled = false,
|
||||||
|
spin = false,
|
||||||
|
className = '',
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`compact-button ${className}`.trim()}
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
aria-label={ariaLabel || title}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{icon && <FontAwesomeIcon icon={icon} spin={spin} />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CompactButton
|
||||||
|
|
||||||
56
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
variant?: 'danger' | 'warning' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
variant = 'warning'
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||||
|
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={`confirm-dialog-icon ${variant}`}>
|
||||||
|
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||||
|
</div>
|
||||||
|
<h3 className="confirm-dialog-title">{title}</h3>
|
||||||
|
<p className="confirm-dialog-message">{message}</p>
|
||||||
|
<div className="confirm-dialog-actions">
|
||||||
|
<button
|
||||||
|
className="confirm-dialog-btn cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`confirm-dialog-btn confirm ${variant}`}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmDialog
|
||||||
|
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
||||||
|
import ReactPlayer from 'react-player'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import rehypeRaw from 'rehype-raw'
|
||||||
|
import rehypePrism from 'rehype-prism-plus'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import 'prismjs/themes/prism-tomorrow.css'
|
||||||
|
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { ContentSkeleton } from './Skeletons'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { getNostrUrl } from '../config/nostrGateways'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { readingTime } from 'reading-time-estimator'
|
import { readingTime } from 'reading-time-estimator'
|
||||||
import { hexToRgb } from '../utils/colorHelpers'
|
import { hexToRgb } from '../utils/colorHelpers'
|
||||||
@@ -12,6 +23,19 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
|||||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import {
|
||||||
|
createEventReaction,
|
||||||
|
createWebsiteReaction,
|
||||||
|
hasMarkedEventAsRead,
|
||||||
|
hasMarkedWebsiteAsRead
|
||||||
|
} from '../services/reactionService'
|
||||||
|
import AuthorCard from './AuthorCard'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||||
|
import { classifyUrl } from '../utils/helpers'
|
||||||
|
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||||
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
|
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -32,9 +56,15 @@ interface ContentPanelProps {
|
|||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
followedPubkeys?: Set<string>
|
followedPubkeys?: Set<string>
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
activeAccount?: IAccount | null
|
||||||
|
currentArticle?: NostrEvent | null
|
||||||
// For highlight creation
|
// For highlight creation
|
||||||
onTextSelection?: (text: string) => void
|
onTextSelection?: (text: string) => void
|
||||||
onClearSelection?: () => void
|
onClearSelection?: () => void
|
||||||
|
// For reading progress indicator positioning
|
||||||
|
isSidebarCollapsed?: boolean
|
||||||
|
isHighlightsCollapsed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||||
@@ -51,15 +81,30 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
highlightStyle = 'marker',
|
highlightStyle = 'marker',
|
||||||
highlightColor = '#ffff00',
|
highlightColor = '#ffff00',
|
||||||
settings,
|
settings,
|
||||||
|
relayPool,
|
||||||
|
activeAccount,
|
||||||
|
currentArticle,
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
currentUserPubkey,
|
currentUserPubkey,
|
||||||
followedPubkeys = new Set(),
|
followedPubkeys = new Set(),
|
||||||
onTextSelection,
|
onTextSelection,
|
||||||
onClearSelection
|
onClearSelection,
|
||||||
|
isSidebarCollapsed = false,
|
||||||
|
isHighlightsCollapsed = false
|
||||||
}) => {
|
}) => {
|
||||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
|
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||||
|
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||||
|
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||||
|
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||||
|
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||||
|
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||||
|
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const externalMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||||
|
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||||
|
|
||||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||||
html,
|
html,
|
||||||
@@ -74,13 +119,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
followedPubkeys
|
followedPubkeys
|
||||||
})
|
})
|
||||||
|
|
||||||
const { contentRef, handleMouseUp } = useHighlightInteractions({
|
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
onTextSelection,
|
onTextSelection,
|
||||||
onClearSelection
|
onClearSelection
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reading position tracking - only for text content, not videos
|
||||||
|
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||||
|
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
||||||
|
enabled: isTextContent,
|
||||||
|
onReadingComplete: () => {
|
||||||
|
// Optional: Auto-mark as read when reading is complete
|
||||||
|
if (activeAccount && !isMarkedAsRead) {
|
||||||
|
// Could trigger auto-mark as read here if desired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||||
|
setShowExternalMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||||
|
|
||||||
const readingStats = useMemo(() => {
|
const readingStats = useMemo(() => {
|
||||||
const content = markdown || html || ''
|
const content = markdown || html || ''
|
||||||
if (!content) return null
|
if (!content) return null
|
||||||
@@ -90,6 +170,233 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const hasHighlights = relevantHighlights.length > 0
|
const hasHighlights = relevantHighlights.length > 0
|
||||||
|
|
||||||
|
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||||
|
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||||
|
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||||
|
|
||||||
|
// Track external video duration (in seconds) for display in header
|
||||||
|
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||||
|
// Load YouTube metadata/captions when applicable
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!selectedUrl) return setYtMeta(null)
|
||||||
|
const id = extractYouTubeId(selectedUrl)
|
||||||
|
if (!id) return setYtMeta(null)
|
||||||
|
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||||
|
const data = await getYouTubeMeta(id, locale)
|
||||||
|
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||||
|
} catch {
|
||||||
|
setYtMeta(null)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [selectedUrl])
|
||||||
|
|
||||||
|
const formatDuration = (totalSeconds: number): string => {
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = Math.floor(totalSeconds % 60)
|
||||||
|
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||||
|
const ss = String(seconds).padStart(2, '0')
|
||||||
|
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Get article links for menu
|
||||||
|
const getArticleLinks = () => {
|
||||||
|
if (!currentArticle) return null
|
||||||
|
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const relayHints = RELAYS.filter(r =>
|
||||||
|
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||||
|
).slice(0, 3)
|
||||||
|
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: currentArticle.pubkey,
|
||||||
|
identifier: dTag,
|
||||||
|
relays: relayHints
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
portal: getNostrUrl(naddr),
|
||||||
|
native: `nostr:${naddr}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleLinks = getArticleLinks()
|
||||||
|
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
setShowArticleMenu(!showArticleMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||||
|
|
||||||
|
const handleOpenPortal = () => {
|
||||||
|
if (articleLinks) {
|
||||||
|
window.open(articleLinks.portal, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenNative = () => {
|
||||||
|
if (articleLinks) {
|
||||||
|
window.location.href = articleLinks.native
|
||||||
|
}
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video actions
|
||||||
|
const handleOpenVideoExternal = () => {
|
||||||
|
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenVideoNative = () => {
|
||||||
|
if (!selectedUrl) return
|
||||||
|
const native = buildNativeVideoUrl(selectedUrl)
|
||||||
|
if (native) {
|
||||||
|
window.location.href = native
|
||||||
|
} else {
|
||||||
|
window.location.href = selectedUrl
|
||||||
|
}
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyVideoUrl = async () => {
|
||||||
|
try {
|
||||||
|
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Clipboard copy failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareVideoUrl = async () => {
|
||||||
|
try {
|
||||||
|
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||||
|
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
|
||||||
|
} else if (selectedUrl) {
|
||||||
|
await navigator.clipboard.writeText(selectedUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// External article actions
|
||||||
|
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||||
|
|
||||||
|
const handleOpenExternalUrl = () => {
|
||||||
|
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowExternalMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyExternalUrl = async () => {
|
||||||
|
try {
|
||||||
|
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Clipboard copy failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowExternalMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareExternalUrl = async () => {
|
||||||
|
try {
|
||||||
|
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||||
|
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
|
||||||
|
} else if (selectedUrl) {
|
||||||
|
await navigator.clipboard.writeText(selectedUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowExternalMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if article is already marked as read when URL/article changes
|
||||||
|
useEffect(() => {
|
||||||
|
const checkReadStatus = async () => {
|
||||||
|
if (!activeAccount || !relayPool || !selectedUrl) {
|
||||||
|
setIsMarkedAsRead(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCheckingReadStatus(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let hasRead = false
|
||||||
|
if (isNostrArticle && currentArticle) {
|
||||||
|
hasRead = await hasMarkedEventAsRead(
|
||||||
|
currentArticle.id,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
hasRead = await hasMarkedWebsiteAsRead(
|
||||||
|
selectedUrl,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setIsMarkedAsRead(hasRead)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check read status:', error)
|
||||||
|
} finally {
|
||||||
|
setIsCheckingReadStatus(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkReadStatus()
|
||||||
|
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||||
|
|
||||||
|
const handleMarkAsRead = () => {
|
||||||
|
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantly update UI with checkmark animation
|
||||||
|
setIsMarkedAsRead(true)
|
||||||
|
setShowCheckAnimation(true)
|
||||||
|
|
||||||
|
// Reset animation after it completes
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowCheckAnimation(false)
|
||||||
|
}, 600)
|
||||||
|
|
||||||
|
// Fire-and-forget: publish in background without blocking UI
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
if (isNostrArticle && currentArticle) {
|
||||||
|
await createEventReaction(
|
||||||
|
currentArticle.id,
|
||||||
|
currentArticle.pubkey,
|
||||||
|
currentArticle.kind,
|
||||||
|
activeAccount,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
console.log('✅ Marked nostr article as read')
|
||||||
|
} else if (selectedUrl) {
|
||||||
|
await createWebsiteReaction(
|
||||||
|
selectedUrl,
|
||||||
|
activeAccount,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
console.log('✅ Marked website as read')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark as read:', error)
|
||||||
|
// Revert UI state on error
|
||||||
|
setIsMarkedAsRead(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedUrl) {
|
if (!selectedUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="reader empty">
|
<div className="reader empty">
|
||||||
@@ -100,10 +407,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="reader loading">
|
<div className="reader" aria-busy="true">
|
||||||
<div className="loading-spinner">
|
<ContentSkeleton />
|
||||||
<FontAwesomeIcon icon={faSpinner} spin />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -111,56 +416,268 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
const highlightRgb = hexToRgb(highlightColor)
|
const highlightRgb = hexToRgb(highlightColor)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
<>
|
||||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
{/* Reading Progress Indicator - Outside reader for fixed positioning */}
|
||||||
|
{isTextContent && (
|
||||||
|
<ReadingProgressIndicator
|
||||||
|
progress={progressPercentage}
|
||||||
|
isComplete={isReadingComplete}
|
||||||
|
showPercentage={true}
|
||||||
|
isSidebarCollapsed={isSidebarCollapsed}
|
||||||
|
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||||
|
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||||
{markdown && (
|
{markdown && (
|
||||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown
|
||||||
{markdown}
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||||
|
components={{
|
||||||
|
img: ({ src, alt, ...props }) => (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processedMarkdown || markdown}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ReaderHeader
|
<ReaderHeader
|
||||||
title={title}
|
title={ytMeta?.title || title}
|
||||||
image={image}
|
image={image}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
published={published}
|
published={published}
|
||||||
readingTimeText={readingStats ? readingStats.text : null}
|
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
||||||
hasHighlights={hasHighlights}
|
hasHighlights={hasHighlights}
|
||||||
highlightCount={relevantHighlights.length}
|
highlightCount={relevantHighlights.length}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
highlights={relevantHighlights}
|
||||||
|
highlightVisibility={highlightVisibility}
|
||||||
/>
|
/>
|
||||||
{markdown || html ? (
|
{isExternalVideo ? (
|
||||||
markdown ? (
|
<>
|
||||||
renderedMarkdownHtml && finalHtml ? (
|
<div className="reader-video">
|
||||||
<div
|
<ReactPlayer
|
||||||
ref={contentRef}
|
url={selectedUrl as string}
|
||||||
className="reader-markdown"
|
controls
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
width="100%"
|
||||||
onMouseUp={handleMouseUp}
|
height="auto"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: '16/9'
|
||||||
|
}}
|
||||||
|
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="reader-markdown">
|
{ytMeta?.description && (
|
||||||
<div className="loading-spinner">
|
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
{ytMeta.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ytMeta?.transcript && (
|
||||||
|
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||||
|
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||||
|
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||||
|
{ytMeta.transcript}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
) : (
|
<div className="article-menu-container">
|
||||||
<div
|
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||||
ref={contentRef}
|
<button
|
||||||
className="reader-html"
|
className="article-menu-btn"
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
onClick={toggleVideoMenu}
|
||||||
onMouseUp={handleMouseUp}
|
title="More options"
|
||||||
/>
|
>
|
||||||
)
|
<FontAwesomeIcon icon={faEllipsisH} />
|
||||||
|
</button>
|
||||||
|
{showVideoMenu && (
|
||||||
|
<div className="article-menu">
|
||||||
|
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open Link</span>
|
||||||
|
</button>
|
||||||
|
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} />
|
||||||
|
<span>Open in Native App</span>
|
||||||
|
</button>
|
||||||
|
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
<span>Copy URL</span>
|
||||||
|
</button>
|
||||||
|
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||||
|
<FontAwesomeIcon icon={faShare} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeAccount && (
|
||||||
|
<div className="mark-as-read-container">
|
||||||
|
<button
|
||||||
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||||
|
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||||
|
spin={isCheckingReadStatus}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : markdown || html ? (
|
||||||
|
<>
|
||||||
|
{markdown ? (
|
||||||
|
renderedMarkdownHtml && finalHtml ? (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="reader-markdown"
|
||||||
|
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||||
|
onMouseUp={handleSelectionEnd}
|
||||||
|
onTouchEnd={handleSelectionEnd}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="reader-markdown">
|
||||||
|
<div className="loading-spinner">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="reader-html"
|
||||||
|
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||||
|
onMouseUp={handleSelectionEnd}
|
||||||
|
onTouchEnd={handleSelectionEnd}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Article menu for external URLs */}
|
||||||
|
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
||||||
|
<div className="article-menu-container">
|
||||||
|
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||||
|
<button
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={toggleExternalMenu}
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsisH} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showExternalMenu && (
|
||||||
|
<div className="article-menu">
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleOpenExternalUrl}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open Original URL</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleCopyExternalUrl}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
<span>Copy URL</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleShareExternalUrl}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faShare} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Article menu for nostr-native articles */}
|
||||||
|
{isNostrArticle && currentArticle && articleLinks && (
|
||||||
|
<div className="article-menu-container">
|
||||||
|
<div className="article-menu-wrapper" ref={articleMenuRef}>
|
||||||
|
<button
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handleMenuToggle}
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsisH} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showArticleMenu && (
|
||||||
|
<div className="article-menu">
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open on Nostr</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleOpenNative}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} />
|
||||||
|
<span>Open with Native App</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mark as Read button */}
|
||||||
|
{activeAccount && (
|
||||||
|
<div className="mark-as-read-container">
|
||||||
|
<button
|
||||||
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||||
|
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||||
|
spin={isCheckingReadStatus}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Author info card for nostr-native articles */}
|
||||||
|
{isNostrArticle && currentArticle && (
|
||||||
|
<div className="author-card-container">
|
||||||
|
<AuthorCard authorPubkey={currentArticle.pubkey} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="reader empty">
|
<div className="reader empty">
|
||||||
<p>No readable content found for this URL.</p>
|
<p>No readable content found for this URL.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,69 +1,227 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import IconButton from './IconButton'
|
||||||
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { fetchContacts } from '../services/contactService'
|
import { fetchContacts } from '../services/contactService'
|
||||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
|
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
|
import { fetchProfiles } from '../services/profileService'
|
||||||
|
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
|
import { HighlightItem } from './HighlightItem'
|
||||||
|
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||||
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
|
|
||||||
interface ExploreProps {
|
interface ExploreProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
settings?: UserSettings
|
||||||
|
activeTab?: TabType
|
||||||
}
|
}
|
||||||
|
|
||||||
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
type TabType = 'writings' | 'highlights'
|
||||||
|
|
||||||
|
const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, activeTab: propActiveTab }) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||||
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
|
// Visibility filters (defaults from settings, or friends only)
|
||||||
|
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||||
|
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
||||||
|
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
||||||
|
mine: settings?.defaultHighlightVisibilityMine ?? false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update local state when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (propActiveTab) {
|
||||||
|
setActiveTab(propActiveTab)
|
||||||
|
}
|
||||||
|
}, [propActiveTab])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadBlogPosts = async () => {
|
const loadData = async () => {
|
||||||
if (!activeAccount) {
|
if (!activeAccount) {
|
||||||
setError('Please log in to explore content from your friends')
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// show spinner but keep existing data
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
|
||||||
|
// Seed from in-memory cache if available to avoid empty flash
|
||||||
|
// Use functional update to check current state without creating dependency
|
||||||
|
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||||
|
if (cachedPosts && cachedPosts.length > 0) {
|
||||||
|
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
|
||||||
|
}
|
||||||
|
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||||
|
if (cachedHighlights && cachedHighlights.length > 0) {
|
||||||
|
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch the user's contacts (friends)
|
// Fetch the user's contacts (friends)
|
||||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
const contacts = await fetchContacts(
|
||||||
|
|
||||||
if (contacts.size === 0) {
|
|
||||||
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relay URLs from pool
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
|
|
||||||
// Fetch blog posts from friends
|
|
||||||
const posts = await fetchBlogPostsFromAuthors(
|
|
||||||
relayPool,
|
relayPool,
|
||||||
Array.from(contacts),
|
activeAccount.pubkey,
|
||||||
relayUrls
|
(partial) => {
|
||||||
|
// Store followed pubkeys for highlight classification
|
||||||
|
setFollowedPubkeys(partial)
|
||||||
|
// When local contacts are available, kick off early fetch
|
||||||
|
if (partial.size > 0) {
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const partialArray = Array.from(partial)
|
||||||
|
|
||||||
|
// Fetch blog posts
|
||||||
|
fetchBlogPostsFromAuthors(
|
||||||
|
relayPool,
|
||||||
|
partialArray,
|
||||||
|
relayUrls,
|
||||||
|
(post) => {
|
||||||
|
setBlogPosts((prev) => {
|
||||||
|
const exists = prev.some(p => p.event.id === post.event.id)
|
||||||
|
if (exists) return prev
|
||||||
|
const next = [...prev, post]
|
||||||
|
return next.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||||
|
}
|
||||||
|
).then((all) => {
|
||||||
|
setBlogPosts((prev) => {
|
||||||
|
const byId = new Map(prev.map(p => [p.event.id, p]))
|
||||||
|
for (const post of all) byId.set(post.event.id, post)
|
||||||
|
const merged = Array.from(byId.values()).sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
setCachedPosts(activeAccount.pubkey, merged)
|
||||||
|
return merged
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch highlights
|
||||||
|
fetchHighlightsFromAuthors(
|
||||||
|
relayPool,
|
||||||
|
partialArray,
|
||||||
|
(highlight) => {
|
||||||
|
setHighlights((prev) => {
|
||||||
|
const exists = prev.some(h => h.id === highlight.id)
|
||||||
|
if (exists) return prev
|
||||||
|
const next = [...prev, highlight]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||||
|
}
|
||||||
|
).then((all) => {
|
||||||
|
setHighlights((prev) => {
|
||||||
|
const byId = new Map(prev.map(h => [h.id, h]))
|
||||||
|
for (const highlight of all) byId.set(highlight.id, highlight)
|
||||||
|
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||||
|
setCachedHighlights(activeAccount.pubkey, merged)
|
||||||
|
return merged
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Always proceed to load nostrverse content even if no contacts
|
||||||
|
// (removed blocking error for empty contacts)
|
||||||
|
|
||||||
if (posts.length === 0) {
|
// Store final followed pubkeys
|
||||||
setError('No blog posts found from your friends yet')
|
setFollowedPubkeys(contacts)
|
||||||
|
|
||||||
|
// Fetch both friends content and nostrverse content in parallel
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const contactsArray = Array.from(contacts)
|
||||||
|
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||||
|
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
||||||
|
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
|
||||||
|
fetchNostrverseHighlights(relayPool, 100)
|
||||||
|
])
|
||||||
|
|
||||||
|
// Merge and deduplicate all posts
|
||||||
|
const allPosts = [...friendsPosts, ...nostrversePosts]
|
||||||
|
const postsByKey = new Map<string, BlogPostPreview>()
|
||||||
|
for (const post of allPosts) {
|
||||||
|
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
|
||||||
|
const existing = postsByKey.get(key)
|
||||||
|
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||||
|
postsByKey.set(key, post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merge and deduplicate all highlights
|
||||||
|
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
|
||||||
|
const highlightsByKey = new Map<string, Highlight>()
|
||||||
|
for (const highlight of allHighlights) {
|
||||||
|
highlightsByKey.set(highlight.id, highlight)
|
||||||
|
}
|
||||||
|
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
|
// Fetch profiles for all blog post authors to cache them
|
||||||
|
if (uniquePosts.length > 0) {
|
||||||
|
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
|
||||||
|
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
|
||||||
|
console.error('Failed to fetch author profiles:', err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setBlogPosts(posts)
|
// No blocking errors - let empty states handle messaging
|
||||||
|
setBlogPosts(uniquePosts)
|
||||||
|
setCachedPosts(activeAccount.pubkey, uniquePosts)
|
||||||
|
|
||||||
|
setHighlights(uniqueHighlights)
|
||||||
|
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load blog posts:', err)
|
console.error('Failed to load data:', err)
|
||||||
setError('Failed to load blog posts. Please try again.')
|
// No blocking error - user can pull-to-refresh
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadBlogPosts()
|
loadData()
|
||||||
}, [relayPool, activeAccount])
|
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||||
|
|
||||||
|
// Pull-to-refresh
|
||||||
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
setRefreshTrigger(prev => prev + 1)
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !activeAccount
|
||||||
|
})
|
||||||
|
|
||||||
const getPostUrl = (post: BlogPostPreview) => {
|
const getPostUrl = (post: BlogPostPreview) => {
|
||||||
// Get the d-tag identifier
|
// Get the d-tag identifier
|
||||||
@@ -79,48 +237,222 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
|||||||
return `/a/${naddr}`
|
return `/a/${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const handleHighlightClick = (highlightId: string) => {
|
||||||
return (
|
const highlight = highlights.find(h => h.id === highlightId)
|
||||||
<div className="explore-container">
|
if (!highlight) return
|
||||||
<div className="explore-loading">
|
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
// For nostr-native articles
|
||||||
<p>Loading blog posts from your friends...</p>
|
if (highlight.eventReference) {
|
||||||
</div>
|
// Convert eventReference to naddr
|
||||||
</div>
|
if (highlight.eventReference.includes(':')) {
|
||||||
)
|
const parts = highlight.eventReference.split(':')
|
||||||
|
const kind = parseInt(parts[0])
|
||||||
|
const pubkey = parts[1]
|
||||||
|
const identifier = parts[2] || ''
|
||||||
|
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind,
|
||||||
|
pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
||||||
|
} else {
|
||||||
|
// Already an naddr
|
||||||
|
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For web URLs
|
||||||
|
else if (highlight.urlReference) {
|
||||||
|
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
// Classify highlights with levels based on user context and apply visibility filters
|
||||||
return (
|
const classifiedHighlights = useMemo(() => {
|
||||||
<div className="explore-container">
|
const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||||
<div className="explore-error">
|
return classified.filter(h => {
|
||||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
if (h.level === 'mine' && !visibility.mine) return false
|
||||||
<p>{error}</p>
|
if (h.level === 'friends' && !visibility.friends) return false
|
||||||
</div>
|
if (h.level === 'nostrverse' && !visibility.nostrverse) return false
|
||||||
</div>
|
return true
|
||||||
)
|
})
|
||||||
|
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
||||||
|
|
||||||
|
// Filter blog posts by future dates and visibility, and add level classification
|
||||||
|
const filteredBlogPosts = useMemo(() => {
|
||||||
|
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||||
|
return blogPosts
|
||||||
|
.filter(post => {
|
||||||
|
// Filter out future dates
|
||||||
|
const publishedTime = post.published || post.event.created_at
|
||||||
|
if (publishedTime > maxFutureTime) return false
|
||||||
|
|
||||||
|
// Apply visibility filters
|
||||||
|
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||||
|
const isFriend = followedPubkeys.has(post.author)
|
||||||
|
const isNostrverse = !isMine && !isFriend
|
||||||
|
|
||||||
|
if (isMine && !visibility.mine) return false
|
||||||
|
if (isFriend && !visibility.friends) return false
|
||||||
|
if (isNostrverse && !visibility.nostrverse) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(post => {
|
||||||
|
// Add level classification
|
||||||
|
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||||
|
const isFriend = followedPubkeys.has(post.author)
|
||||||
|
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||||
|
return { ...post, level }
|
||||||
|
})
|
||||||
|
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'writings':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return filteredBlogPosts.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{filteredBlogPosts.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
level={post.level}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'highlights':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<HighlightSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return classifiedHighlights.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{classifiedHighlights.map((highlight) => (
|
||||||
|
<HighlightItem
|
||||||
|
key={highlight.id}
|
||||||
|
highlight={highlight}
|
||||||
|
relayPool={relayPool}
|
||||||
|
onHighlightClick={handleHighlightClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show content progressively - no blocking error screens
|
||||||
|
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||||
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="explore-container">
|
<div className="explore-container">
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
<div className="explore-header">
|
<div className="explore-header">
|
||||||
<h1>
|
<h1>
|
||||||
<FontAwesomeIcon icon={faNewspaper} />
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
Explore
|
Explore
|
||||||
</h1>
|
</h1>
|
||||||
<p className="explore-subtitle">
|
|
||||||
Discover blog posts from your friends on Nostr
|
{/* Visibility filters */}
|
||||||
</p>
|
<div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||||
</div>
|
<IconButton
|
||||||
<div className="explore-grid">
|
icon={faArrowsRotate}
|
||||||
{blogPosts.map((post) => (
|
onClick={() => setRefreshTrigger(prev => prev + 1)}
|
||||||
<BlogPostCard
|
title="Refresh content"
|
||||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
ariaLabel="Refresh content"
|
||||||
post={post}
|
variant="ghost"
|
||||||
href={getPostUrl(post)}
|
spin={loading || isRefreshing}
|
||||||
|
disabled={loading || isRefreshing}
|
||||||
/>
|
/>
|
||||||
))}
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
|
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
|
||||||
|
title="Toggle nostrverse content"
|
||||||
|
ariaLabel="Toggle nostrverse content"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: visibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||||
|
opacity: visibility.nostrverse ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
|
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
|
||||||
|
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
||||||
|
ariaLabel="Toggle friends content"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!activeAccount}
|
||||||
|
style={{
|
||||||
|
color: visibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
|
opacity: visibility.friends ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
|
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
|
||||||
|
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
||||||
|
ariaLabel="Toggle my content"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!activeAccount}
|
||||||
|
style={{
|
||||||
|
color: visibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
|
opacity: visibility.mine ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="me-tabs">
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||||
|
data-tab="highlights"
|
||||||
|
onClick={() => navigate('/explore')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span className="tab-label">Highlights</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||||
|
data-tab="writings"
|
||||||
|
onClick={() => navigate('/explore/writings')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
|
<span className="tab-label">Writings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/components/HighlightCitation.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { fetchArticleTitle } from '../services/articleTitleResolver'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
|
||||||
|
interface HighlightCitationProps {
|
||||||
|
highlight: Highlight
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||||
|
highlight,
|
||||||
|
relayPool
|
||||||
|
}) => {
|
||||||
|
const [articleTitle, setArticleTitle] = useState<string>()
|
||||||
|
|
||||||
|
// Extract author pubkey from p tag directly
|
||||||
|
const authorPubkey = useMemo(() => {
|
||||||
|
// First try the extracted author from highlight.author
|
||||||
|
if (highlight.author) {
|
||||||
|
return highlight.author
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract directly from p tag
|
||||||
|
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||||
|
if (pTag && pTag[1]) {
|
||||||
|
console.log('📝 Found author from p tag:', pTag[1])
|
||||||
|
return pTag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}, [highlight.author, highlight.tags])
|
||||||
|
|
||||||
|
const authorProfile = useEventModel(Models.ProfileModel, authorPubkey ? [authorPubkey] : null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlight.eventReference || !relayPool) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTitle = async () => {
|
||||||
|
try {
|
||||||
|
if (!highlight.eventReference) return
|
||||||
|
|
||||||
|
// Convert eventReference to naddr if needed
|
||||||
|
let naddr: string
|
||||||
|
if (highlight.eventReference.includes(':')) {
|
||||||
|
const parts = highlight.eventReference.split(':')
|
||||||
|
const kind = parseInt(parts[0])
|
||||||
|
const pubkey = parts[1]
|
||||||
|
const identifier = parts[2] || ''
|
||||||
|
|
||||||
|
naddr = nip19.naddrEncode({
|
||||||
|
kind,
|
||||||
|
pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
naddr = highlight.eventReference
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = await fetchArticleTitle(relayPool, naddr)
|
||||||
|
if (title) {
|
||||||
|
setArticleTitle(title)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load article title:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTitle()
|
||||||
|
}, [highlight.eventReference, relayPool])
|
||||||
|
|
||||||
|
const authorName = authorProfile?.name || authorProfile?.display_name
|
||||||
|
|
||||||
|
// For nostr-native content with article reference
|
||||||
|
if (highlight.eventReference && (authorName || articleTitle)) {
|
||||||
|
return (
|
||||||
|
<div className="highlight-citation">
|
||||||
|
— {authorName || 'Unknown'}{articleTitle ? `, ${articleTitle}` : ''}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For web URLs
|
||||||
|
if (highlight.urlReference) {
|
||||||
|
try {
|
||||||
|
const url = new URL(highlight.urlReference)
|
||||||
|
return (
|
||||||
|
<div className="highlight-citation">
|
||||||
|
— {url.hostname}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,15 +1,174 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
|
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models, IEventStore } from 'applesauce-core'
|
import { Models, IEventStore } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { areAllRelaysLocal } from '../utils/helpers'
|
import { areAllRelaysLocal } from '../utils/helpers'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||||
|
import { createDeletionRequest } from '../services/deletionService'
|
||||||
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
|
import { getNostrUrl } from '../config/nostrGateways'
|
||||||
|
import CompactButton from './CompactButton'
|
||||||
|
import { HighlightCitation } from './HighlightCitation'
|
||||||
|
|
||||||
|
// Helper to detect if a URL is an image
|
||||||
|
const isImageUrl = (url: string): boolean => {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const pathname = urlObj.pathname.toLowerCase()
|
||||||
|
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/.test(pathname)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to render a nostr identifier
|
||||||
|
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
|
||||||
|
try {
|
||||||
|
// Remove nostr: prefix
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
const decoded = nip19.decode(identifier)
|
||||||
|
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'npub': {
|
||||||
|
const pubkey = decoded.data
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={`/p/${nip19.npubEncode(pubkey)}`}
|
||||||
|
className="highlight-comment-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
@{pubkey.slice(0, 8)}...
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'nprofile': {
|
||||||
|
const { pubkey } = decoded.data
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={`/p/${npub}`}
|
||||||
|
className="highlight-comment-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
@{pubkey.slice(0, 8)}...
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'naddr': {
|
||||||
|
const { kind, pubkey, identifier } = decoded.data
|
||||||
|
// Check if it's a blog post (kind:30023)
|
||||||
|
if (kind === 30023) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={`/a/${naddr}`}
|
||||||
|
className="highlight-comment-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{identifier || 'Article'}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// For other kinds, show shortened identifier
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
nostr:{identifier.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'note': {
|
||||||
|
const eventId = decoded.data
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
note:{eventId.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'nevent': {
|
||||||
|
const { id } = decoded.data
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
event:{id.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Fallback for unrecognized types
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
{identifier.slice(0, 20)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If decoding fails, show shortened identifier
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
{identifier.slice(0, 20)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component to render comment with links, inline images, and nostr identifiers
|
||||||
|
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||||
|
// Pattern to match both http(s) URLs and nostr: URIs
|
||||||
|
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
|
||||||
|
const parts = text.split(urlPattern)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
// Handle nostr: URIs
|
||||||
|
if (part.startsWith('nostr:')) {
|
||||||
|
return renderNostrId(part, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle http(s) URLs
|
||||||
|
if (part.match(/^https?:\/\//)) {
|
||||||
|
if (isImageUrl(part)) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={part}
|
||||||
|
alt="Comment attachment"
|
||||||
|
className="highlight-comment-image"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={part}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="highlight-comment-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={index}>{part}</span>
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface HighlightWithLevel extends Highlight {
|
interface HighlightWithLevel extends Highlight {
|
||||||
level?: 'mine' | 'friends' | 'nostrverse'
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
@@ -23,21 +182,31 @@ interface HighlightItemProps {
|
|||||||
relayPool?: RelayPool | null
|
relayPool?: RelayPool | null
|
||||||
eventStore?: IEventStore | null
|
eventStore?: IEventStore | null
|
||||||
onHighlightUpdate?: (highlight: Highlight) => void
|
onHighlightUpdate?: (highlight: Highlight) => void
|
||||||
|
onHighlightDelete?: (highlightId: string) => void
|
||||||
|
showCitation?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||||
highlight,
|
highlight,
|
||||||
onSelectUrl,
|
// onSelectUrl is not used but kept in props for API compatibility
|
||||||
isSelected,
|
isSelected,
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
onHighlightUpdate
|
onHighlightUpdate,
|
||||||
|
onHighlightDelete,
|
||||||
|
showCitation = true
|
||||||
}) => {
|
}) => {
|
||||||
const itemRef = useRef<HTMLDivElement>(null)
|
const itemRef = useRef<HTMLDivElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
||||||
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
||||||
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
|
||||||
// Resolve the profile of the user who made the highlight
|
// Resolve the profile of the user who made the highlight
|
||||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||||
@@ -88,61 +257,49 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isSelected])
|
}, [isSelected])
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMenu) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showMenu])
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
if (onHighlightClick) {
|
if (onHighlightClick) {
|
||||||
onHighlightClick(highlight.id)
|
onHighlightClick(highlight.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLinkClick = (url: string, e: React.MouseEvent) => {
|
const getHighlightLinks = () => {
|
||||||
if (onSelectUrl) {
|
// Encode the highlight event itself (kind 9802) as a nevent
|
||||||
e.preventDefault()
|
// Get non-local relays for the hint
|
||||||
onSelectUrl(url)
|
const relayHints = RELAYS.filter(r =>
|
||||||
|
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||||
|
).slice(0, 3) // Include up to 3 relay hints
|
||||||
|
|
||||||
|
const nevent = nip19.neventEncode({
|
||||||
|
id: highlight.id,
|
||||||
|
relays: relayHints,
|
||||||
|
author: highlight.pubkey,
|
||||||
|
kind: 9802
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
portal: getNostrUrl(nevent),
|
||||||
|
native: `nostr:${nevent}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSourceLink = () => {
|
const highlightLinks = getHighlightLinks()
|
||||||
if (highlight.eventReference) {
|
|
||||||
// Check if it's a coordinate string (kind:pubkey:identifier) or a simple event ID
|
|
||||||
if (highlight.eventReference.includes(':')) {
|
|
||||||
// It's an addressable event coordinate, encode as naddr
|
|
||||||
const parts = highlight.eventReference.split(':')
|
|
||||||
if (parts.length === 3) {
|
|
||||||
const [kindStr, pubkey, identifier] = parts
|
|
||||||
const kind = parseInt(kindStr, 10)
|
|
||||||
|
|
||||||
// Get non-local relays for the hint
|
|
||||||
const relayHints = RELAYS.filter(r =>
|
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
|
||||||
).slice(0, 3) // Include up to 3 relay hints
|
|
||||||
|
|
||||||
const naddr = nip19.naddrEncode({
|
|
||||||
kind,
|
|
||||||
pubkey,
|
|
||||||
identifier,
|
|
||||||
relays: relayHints
|
|
||||||
})
|
|
||||||
return `https://njump.me/${naddr}`
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// It's a simple event ID, encode as nevent
|
|
||||||
// Get non-local relays for the hint
|
|
||||||
const relayHints = RELAYS.filter(r =>
|
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
|
||||||
).slice(0, 3) // Include up to 3 relay hints
|
|
||||||
|
|
||||||
const nevent = nip19.neventEncode({
|
|
||||||
id: highlight.eventReference,
|
|
||||||
relays: relayHints,
|
|
||||||
author: highlight.author
|
|
||||||
})
|
|
||||||
return `https://njump.me/${nevent}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return highlight.urlReference
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceLink = getSourceLink()
|
|
||||||
|
|
||||||
// Handle rebroadcast to all relays
|
// Handle rebroadcast to all relays
|
||||||
const handleRebroadcast = async (e: React.MouseEvent) => {
|
const handleRebroadcast = async (e: React.MouseEvent) => {
|
||||||
@@ -207,13 +364,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
// Always show relay list, use plane icon for local-only
|
// Always show relay list, use plane icon for local-only
|
||||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
||||||
|
|
||||||
// Show server icon with relay info if available
|
// Show highlighter icon with relay info if available
|
||||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||||
const relayNames = highlight.publishedRelays.map(url =>
|
const relayNames = highlight.publishedRelays.map(url =>
|
||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
icon: isLocalOrOffline ? faPlane : faServer,
|
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
||||||
tooltip: relayNames.join('\n'),
|
tooltip: relayNames.join('\n'),
|
||||||
spin: false
|
spin: false
|
||||||
}
|
}
|
||||||
@@ -224,7 +381,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
icon: faServer,
|
icon: faHighlighter,
|
||||||
tooltip: relayNames.join('\n'),
|
tooltip: relayNames.join('\n'),
|
||||||
spin: false
|
spin: false
|
||||||
}
|
}
|
||||||
@@ -235,7 +392,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
icon: faServer,
|
icon: faHighlighter,
|
||||||
tooltip: relayNames.join('\n'),
|
tooltip: relayNames.join('\n'),
|
||||||
spin: false
|
spin: false
|
||||||
}
|
}
|
||||||
@@ -243,7 +400,69 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
|
|
||||||
const relayIndicator = getRelayIndicatorInfo()
|
const relayIndicator = getRelayIndicatorInfo()
|
||||||
|
|
||||||
|
// Check if current user can delete this highlight
|
||||||
|
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!activeAccount || !relayPool) {
|
||||||
|
console.warn('Cannot delete: no account or relay pool')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createDeletionRequest(
|
||||||
|
highlight.id,
|
||||||
|
9802, // kind for highlights
|
||||||
|
'Deleted by user',
|
||||||
|
activeAccount,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('✅ Highlight deletion request published')
|
||||||
|
|
||||||
|
// Notify parent to remove this highlight from the list
|
||||||
|
if (onHighlightDelete) {
|
||||||
|
onHighlightDelete(highlight.id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete highlight:', error)
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelDelete = () => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuToggle = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMenu(!showMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenPortal = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
window.open(highlightLinks.portal, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenNative = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
window.location.href = highlightLinks.native
|
||||||
|
setShowMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMenu(false)
|
||||||
|
setShowDeleteConfirm(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
ref={itemRef}
|
ref={itemRef}
|
||||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||||
@@ -251,56 +470,119 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
<div className="highlight-quote-icon">
|
<div className="highlight-header">
|
||||||
<FontAwesomeIcon icon={faQuoteLeft} />
|
<CompactButton
|
||||||
{relayIndicator && (
|
className="highlight-timestamp"
|
||||||
<div
|
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||||
className="highlight-relay-indicator"
|
onClick={(e) => {
|
||||||
title={relayIndicator.tooltip}
|
e.stopPropagation()
|
||||||
onClick={handleRebroadcast}
|
window.location.href = highlightLinks.native
|
||||||
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
|
}}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
|
{formatDateCompact(highlight.created_at)}
|
||||||
</div>
|
</CompactButton>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CompactButton
|
||||||
|
className="highlight-quote-button"
|
||||||
|
icon={faQuoteLeft}
|
||||||
|
title="Quote"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* relay indicator lives in footer for consistent padding/alignment */}
|
||||||
|
|
||||||
<div className="highlight-content">
|
<div className="highlight-content">
|
||||||
<blockquote className="highlight-text">
|
<blockquote className="highlight-text">
|
||||||
{highlight.content}
|
{highlight.content}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|
||||||
|
{showCitation && (
|
||||||
|
<HighlightCitation
|
||||||
|
highlight={highlight}
|
||||||
|
relayPool={relayPool}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{highlight.comment && (
|
{highlight.comment && (
|
||||||
<div className="highlight-comment">
|
<div className="highlight-comment">
|
||||||
{highlight.comment}
|
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
|
||||||
|
<div className="highlight-comment-text">
|
||||||
|
<CommentContent text={highlight.comment} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="highlight-meta">
|
<div className="highlight-footer">
|
||||||
<span className="highlight-author">
|
<div className="highlight-footer-left">
|
||||||
{getUserDisplayName()}
|
{relayIndicator && (
|
||||||
</span>
|
<CompactButton
|
||||||
<span className="highlight-meta-separator">•</span>
|
className="highlight-relay-indicator"
|
||||||
<span className="highlight-time">
|
icon={relayIndicator.icon}
|
||||||
{formatDateCompact(highlight.created_at)}
|
spin={relayIndicator.spin}
|
||||||
</span>
|
title={relayIndicator.tooltip}
|
||||||
|
onClick={handleRebroadcast}
|
||||||
|
disabled={!relayPool || !eventStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="highlight-author">
|
||||||
|
{getUserDisplayName()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{sourceLink && (
|
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||||
<a
|
<CompactButton
|
||||||
href={sourceLink}
|
icon={faEllipsisH}
|
||||||
target="_blank"
|
onClick={handleMenuToggle}
|
||||||
rel="noopener noreferrer"
|
title="More options"
|
||||||
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
|
/>
|
||||||
className="highlight-source"
|
|
||||||
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
|
{showMenu && (
|
||||||
>
|
<div className="highlight-menu">
|
||||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
<button
|
||||||
</a>
|
className="highlight-menu-item"
|
||||||
)}
|
onClick={handleOpenPortal}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open on Nostr</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item"
|
||||||
|
onClick={handleOpenNative}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} />
|
||||||
|
<span>Open with Native App</span>
|
||||||
|
</button>
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item highlight-menu-item-danger"
|
||||||
|
onClick={handleMenuDeleteClick}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
title="Delete Highlight?"
|
||||||
|
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
|
||||||
|
confirmText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={handleCancelDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
|
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
|
||||||
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
||||||
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { HighlightSkeleton } from './Skeletons'
|
||||||
|
|
||||||
export interface HighlightVisibility {
|
export interface HighlightVisibility {
|
||||||
nostrverse: boolean
|
nostrverse: boolean
|
||||||
@@ -32,6 +36,7 @@ interface HighlightsPanelProps {
|
|||||||
followedPubkeys?: Set<string>
|
followedPubkeys?: Set<string>
|
||||||
relayPool?: RelayPool | null
|
relayPool?: RelayPool | null
|
||||||
eventStore?: IEventStore | null
|
eventStore?: IEventStore | null
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||||
@@ -50,7 +55,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
onHighlightVisibilityChange,
|
onHighlightVisibilityChange,
|
||||||
followedPubkeys = new Set(),
|
followedPubkeys = new Set(),
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore
|
eventStore,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
const [showHighlights, setShowHighlights] = useState(true)
|
const [showHighlights, setShowHighlights] = useState(true)
|
||||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||||
@@ -60,6 +66,18 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
setShowHighlights(newValue)
|
setShowHighlights(newValue)
|
||||||
onToggleHighlights?.(newValue)
|
onToggleHighlights?.(newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull-to-refresh for highlights
|
||||||
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !onRefresh
|
||||||
|
})
|
||||||
|
|
||||||
// Keep track of highlight updates
|
// Keep track of highlight updates
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -72,6 +90,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleHighlightDelete = (highlightId: string) => {
|
||||||
|
// Remove highlight from local state
|
||||||
|
setLocalHighlights(prev => prev.filter(h => h.id !== highlightId))
|
||||||
|
}
|
||||||
|
|
||||||
const filteredHighlights = useFilteredHighlights({
|
const filteredHighlights = useFilteredHighlights({
|
||||||
highlights: localHighlights,
|
highlights: localHighlights,
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
@@ -85,6 +108,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
<HighlightsPanelCollapsed
|
<HighlightsPanelCollapsed
|
||||||
hasHighlights={filteredHighlights.length > 0}
|
hasHighlights={filteredHighlights.length > 0}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,8 +128,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && filteredHighlights.length === 0 ? (
|
{loading && filteredHighlights.length === 0 ? (
|
||||||
<div className="highlights-loading">
|
<div className="highlights-list" aria-busy="true">
|
||||||
<FontAwesomeIcon icon={faHighlighter} spin />
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<HighlightSkeleton key={i} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filteredHighlights.length === 0 ? (
|
) : filteredHighlights.length === 0 ? (
|
||||||
<div className="highlights-empty">
|
<div className="highlights-empty">
|
||||||
@@ -119,6 +145,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="highlights-list">
|
<div className="highlights-list">
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
{filteredHighlights.map((highlight) => (
|
{filteredHighlights.map((highlight) => (
|
||||||
<HighlightItem
|
<HighlightItem
|
||||||
key={highlight.id}
|
key={highlight.id}
|
||||||
@@ -129,6 +159,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
eventStore={eventStore}
|
eventStore={eventStore}
|
||||||
onHighlightUpdate={handleHighlightUpdate}
|
onHighlightUpdate={handleHighlightUpdate}
|
||||||
|
onHighlightDelete={handleHighlightDelete}
|
||||||
|
showCitation={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faChevronRight } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faChevronRight } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
interface HighlightsPanelCollapsedProps {
|
interface HighlightsPanelCollapsedProps {
|
||||||
hasHighlights: boolean
|
hasHighlights: boolean
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
|
const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
|
||||||
hasHighlights,
|
hasHighlights,
|
||||||
onToggleCollapse
|
onToggleCollapse,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
|
const highlightColor = settings?.highlightColorMine || '#ffff00'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="highlights-container collapsed">
|
<div className="highlights-container collapsed">
|
||||||
<button
|
<button
|
||||||
@@ -19,8 +24,12 @@ const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
|
|||||||
title="Expand highlights panel"
|
title="Expand highlights panel"
|
||||||
aria-label="Expand highlights panel"
|
aria-label="Expand highlights panel"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} className={hasHighlights ? 'glow' : ''} />
|
<FontAwesomeIcon
|
||||||
<FontAwesomeIcon icon={faChevronRight} />
|
icon={faHighlighter}
|
||||||
|
className={hasHighlights ? 'glow' : ''}
|
||||||
|
style={{ color: highlightColor }}
|
||||||
|
/>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} style={{ color: highlightColor }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { HighlightVisibility } from '../HighlightsPanel'
|
import { HighlightVisibility } from '../HighlightsPanel'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
interface HighlightsPanelHeaderProps {
|
interface HighlightsPanelHeaderProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -32,76 +32,81 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
<div className="highlights-actions-left">
|
<div className="highlights-actions-left">
|
||||||
{onHighlightVisibilityChange && (
|
{onHighlightVisibilityChange && (
|
||||||
<div className="highlight-level-toggles">
|
<div className="highlight-level-toggles">
|
||||||
<button
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
onClick={() => onHighlightVisibilityChange({
|
onClick={() => onHighlightVisibilityChange({
|
||||||
...highlightVisibility,
|
...highlightVisibility,
|
||||||
nostrverse: !highlightVisibility.nostrverse
|
nostrverse: !highlightVisibility.nostrverse
|
||||||
})}
|
})}
|
||||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
|
||||||
title="Toggle nostrverse highlights"
|
title="Toggle nostrverse highlights"
|
||||||
aria-label="Toggle nostrverse highlights"
|
ariaLabel="Toggle nostrverse highlights"
|
||||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
variant="ghost"
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faNetworkWired} />
|
color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||||
</button>
|
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||||
<button
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
onClick={() => onHighlightVisibilityChange({
|
onClick={() => onHighlightVisibilityChange({
|
||||||
...highlightVisibility,
|
...highlightVisibility,
|
||||||
friends: !highlightVisibility.friends
|
friends: !highlightVisibility.friends
|
||||||
})}
|
})}
|
||||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
|
||||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||||
aria-label="Toggle friends highlights"
|
ariaLabel="Toggle friends highlights"
|
||||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
variant="ghost"
|
||||||
disabled={!currentUserPubkey}
|
disabled={!currentUserPubkey}
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faUserGroup} />
|
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
</button>
|
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||||
<button
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
onClick={() => onHighlightVisibilityChange({
|
onClick={() => onHighlightVisibilityChange({
|
||||||
...highlightVisibility,
|
...highlightVisibility,
|
||||||
mine: !highlightVisibility.mine
|
mine: !highlightVisibility.mine
|
||||||
})}
|
})}
|
||||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
|
||||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||||
aria-label="Toggle my highlights"
|
ariaLabel="Toggle my highlights"
|
||||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
variant="ghost"
|
||||||
disabled={!currentUserPubkey}
|
disabled={!currentUserPubkey}
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faUser} />
|
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
</button>
|
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
<button
|
<IconButton
|
||||||
|
icon={faRotate}
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className="refresh-highlights-btn"
|
|
||||||
title="Refresh highlights"
|
title="Refresh highlights"
|
||||||
aria-label="Refresh highlights"
|
ariaLabel="Refresh highlights"
|
||||||
|
variant="ghost"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
spin={loading}
|
||||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
/>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<button
|
<IconButton
|
||||||
|
icon={showHighlights ? faEye : faEyeSlash}
|
||||||
onClick={onToggleHighlights}
|
onClick={onToggleHighlights}
|
||||||
className="toggle-highlight-display-btn"
|
|
||||||
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||||
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
ariaLabel={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||||
>
|
variant="ghost"
|
||||||
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
/>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<IconButton
|
||||||
|
icon={faChevronRight}
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="toggle-highlights-btn"
|
|
||||||
title="Collapse highlights panel"
|
title="Collapse highlights panel"
|
||||||
aria-label="Collapse highlights panel"
|
ariaLabel="Collapse highlights panel"
|
||||||
>
|
variant="ghost"
|
||||||
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
|
style={{ transform: 'rotate(180deg)' }}
|
||||||
</button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface IconButtonProps {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
spin?: boolean
|
spin?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconButton: React.FC<IconButtonProps> = ({
|
const IconButton: React.FC<IconButtonProps> = ({
|
||||||
@@ -23,7 +24,8 @@ const IconButton: React.FC<IconButtonProps> = ({
|
|||||||
size = 33,
|
size = 33,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
spin = false,
|
spin = false,
|
||||||
className = ''
|
className = '',
|
||||||
|
style
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -31,7 +33,7 @@ const IconButton: React.FC<IconButtonProps> = ({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={ariaLabel || title}
|
aria-label={ariaLabel || title}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size, ...style }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={icon} spin={spin} />
|
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||||
|
|||||||
397
src/components/Me.tsx
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { HighlightItem } from './HighlightItem'
|
||||||
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
|
import { fetchReadArticlesWithData } from '../services/libraryService'
|
||||||
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
|
import AuthorCard from './AuthorCard'
|
||||||
|
import BlogPostCard from './BlogPostCard'
|
||||||
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
|
import IconButton from './IconButton'
|
||||||
|
import { ViewMode } from './Bookmarks'
|
||||||
|
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
|
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||||
|
|
||||||
|
interface MeProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
activeTab?: TabType
|
||||||
|
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
||||||
|
|
||||||
|
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
|
// Use provided pubkey or fall back to active account
|
||||||
|
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||||
|
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||||
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
|
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||||
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
|
// Update local state when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (propActiveTab) {
|
||||||
|
setActiveTab(propActiveTab)
|
||||||
|
}
|
||||||
|
}, [propActiveTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!viewingPubkey) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Seed from cache if available to avoid empty flash (own profile only)
|
||||||
|
if (isOwnProfile) {
|
||||||
|
const cached = getCachedMeData(viewingPubkey)
|
||||||
|
if (cached) {
|
||||||
|
setHighlights(cached.highlights)
|
||||||
|
setBookmarks(cached.bookmarks)
|
||||||
|
setReadArticles(cached.readArticles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch highlights and writings (public data)
|
||||||
|
const [userHighlights, userWritings] = await Promise.all([
|
||||||
|
fetchHighlights(relayPool, viewingPubkey),
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||||
|
])
|
||||||
|
|
||||||
|
setHighlights(userHighlights)
|
||||||
|
setWritings(userWritings)
|
||||||
|
|
||||||
|
// Only fetch private data for own profile
|
||||||
|
if (isOwnProfile && activeAccount) {
|
||||||
|
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
||||||
|
setReadArticles(userReadArticles)
|
||||||
|
|
||||||
|
// Fetch bookmarks using callback pattern
|
||||||
|
let fetchedBookmarks: Bookmark[] = []
|
||||||
|
try {
|
||||||
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
|
fetchedBookmarks = newBookmarks
|
||||||
|
setBookmarks(newBookmarks)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load bookmarks:', err)
|
||||||
|
setBookmarks([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache with all fetched data
|
||||||
|
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||||
|
} else {
|
||||||
|
setBookmarks([])
|
||||||
|
setReadArticles([])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load data:', err)
|
||||||
|
// No blocking error - user can pull-to-refresh
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||||
|
|
||||||
|
// Pull-to-refresh
|
||||||
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
setRefreshTrigger(prev => prev + 1)
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !viewingPubkey
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleHighlightDelete = (highlightId: string) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const updated = prev.filter(h => h.id !== highlightId)
|
||||||
|
// Update cache when highlight is deleted (own profile only)
|
||||||
|
if (isOwnProfile && viewingPubkey) {
|
||||||
|
updateCachedHighlights(viewingPubkey, updated)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPostUrl = (post: BlogPostPreview) => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return `/a/${naddr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||||
|
if (bookmark && bookmark.kind === 30023) {
|
||||||
|
// For kind:30023 articles, navigate to the article route
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
if (dTag && bookmark.pubkey) {
|
||||||
|
const pointer = {
|
||||||
|
identifier: dTag,
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
}
|
||||||
|
const naddr = nip19.naddrEncode(pointer)
|
||||||
|
navigate(`/a/${naddr}`)
|
||||||
|
}
|
||||||
|
} else if (url) {
|
||||||
|
// For regular URLs, navigate to the reader route
|
||||||
|
navigate(`/r/${encodeURIComponent(url)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and flatten all individual bookmarks
|
||||||
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
.filter(hasContent)
|
||||||
|
const groups = groupIndividualBookmarks(allIndividualBookmarks)
|
||||||
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
|
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems },
|
||||||
|
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems },
|
||||||
|
{ key: 'web', title: 'Web bookmarks', items: groups.web },
|
||||||
|
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Show content progressively - no blocking error screens
|
||||||
|
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
||||||
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'highlights':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<HighlightSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return highlights.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="highlights-list me-highlights-list">
|
||||||
|
{highlights.map((highlight) => (
|
||||||
|
<HighlightItem
|
||||||
|
key={highlight.id}
|
||||||
|
highlight={{ ...highlight, level: 'mine' }}
|
||||||
|
relayPool={relayPool}
|
||||||
|
onHighlightDelete={handleHighlightDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'reading-list':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="bookmarks-list">
|
||||||
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return allIndividualBookmarks.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bookmarks-list">
|
||||||
|
{sections.filter(s => s.items.length > 0).map(section => (
|
||||||
|
<div key={section.key} className="bookmarks-section">
|
||||||
|
<h3 className="bookmarks-section-title">{section.title}</h3>
|
||||||
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
|
{section.items.map((individualBookmark, index) => (
|
||||||
|
<BookmarkItem
|
||||||
|
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||||
|
bookmark={individualBookmark}
|
||||||
|
index={index}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onSelectUrl={handleSelectUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="view-mode-controls" style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
borderTop: '1px solid var(--border-color)'
|
||||||
|
}}>
|
||||||
|
<IconButton
|
||||||
|
icon={faList}
|
||||||
|
onClick={() => setViewMode('compact')}
|
||||||
|
title="Compact list view"
|
||||||
|
ariaLabel="Compact list view"
|
||||||
|
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faThLarge}
|
||||||
|
onClick={() => setViewMode('cards')}
|
||||||
|
title="Cards view"
|
||||||
|
ariaLabel="Cards view"
|
||||||
|
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faImage}
|
||||||
|
onClick={() => setViewMode('large')}
|
||||||
|
title="Large preview view"
|
||||||
|
ariaLabel="Large preview view"
|
||||||
|
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'archive':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return readArticles.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{readArticles.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={post.event.id}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'writings':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return writings.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{writings.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={post.event.id}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="explore-container">
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
|
<div className="explore-header">
|
||||||
|
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
|
||||||
|
|
||||||
|
<div className="me-tabs">
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||||
|
data-tab="highlights"
|
||||||
|
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span className="tab-label">Highlights</span>
|
||||||
|
</button>
|
||||||
|
{isOwnProfile && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||||
|
data-tab="reading-list"
|
||||||
|
onClick={() => navigate('/me/reading-list')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBookmark} />
|
||||||
|
<span className="tab-label">Bookmarks</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||||
|
data-tab="archive"
|
||||||
|
onClick={() => navigate('/me/archive')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
|
<span className="tab-label">Archive</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||||
|
data-tab="writings"
|
||||||
|
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
|
<span className="tab-label">Writings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="me-tab-content">
|
||||||
|
{renderTabContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Me
|
||||||
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { useImageCache } from '../hooks/useImageCache'
|
import { useImageCache } from '../hooks/useImageCache'
|
||||||
|
import { useAdaptiveTextColor } from '../hooks/useAdaptiveTextColor'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { Highlight, HighlightLevel } from '../types/highlights'
|
||||||
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
|
import { hexToRgb } from '../utils/colorHelpers'
|
||||||
|
|
||||||
interface ReaderHeaderProps {
|
interface ReaderHeaderProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -14,6 +18,8 @@ interface ReaderHeaderProps {
|
|||||||
hasHighlights: boolean
|
hasHighlights: boolean
|
||||||
highlightCount: number
|
highlightCount: number
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
highlights?: Highlight[]
|
||||||
|
highlightVisibility?: HighlightVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||||
@@ -24,40 +30,100 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
readingTimeText,
|
readingTimeText,
|
||||||
hasHighlights,
|
hasHighlights,
|
||||||
highlightCount,
|
highlightCount,
|
||||||
settings
|
settings,
|
||||||
|
highlights = [],
|
||||||
|
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(image, settings)
|
const cachedImage = useImageCache(image)
|
||||||
|
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||||
if (cachedImage) {
|
const isLongSummary = summary && summary.length > 150
|
||||||
|
|
||||||
|
// Determine the dominant highlight color based on visibility and priority
|
||||||
|
const getHighlightIndicatorStyles = useMemo(() => (isOverlay: boolean) => {
|
||||||
|
if (!highlights.length) return undefined
|
||||||
|
|
||||||
|
// Count highlights by level that are visible
|
||||||
|
const visibleLevels = new Set<HighlightLevel>()
|
||||||
|
highlights.forEach(h => {
|
||||||
|
if (h.level && highlightVisibility[h.level]) {
|
||||||
|
visibleLevels.add(h.level)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let hexColor: string | undefined
|
||||||
|
// Priority: nostrverse > friends > mine
|
||||||
|
if (visibleLevels.has('nostrverse') && highlightVisibility.nostrverse) {
|
||||||
|
hexColor = settings?.highlightColorNostrverse || '#9333ea'
|
||||||
|
} else if (visibleLevels.has('friends') && highlightVisibility.friends) {
|
||||||
|
hexColor = settings?.highlightColorFriends || '#f97316'
|
||||||
|
} else if (visibleLevels.has('mine') && highlightVisibility.mine) {
|
||||||
|
hexColor = settings?.highlightColorMine || '#ffff00'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hexColor) return undefined
|
||||||
|
|
||||||
|
const rgb = hexToRgb(hexColor)
|
||||||
|
return {
|
||||||
|
backgroundColor: `rgba(${rgb}, 0.1)`,
|
||||||
|
borderColor: `rgba(${rgb}, 0.3)`,
|
||||||
|
// Only force white color in overlay context, otherwise let CSS handle it
|
||||||
|
...(isOverlay && { color: '#fff' })
|
||||||
|
}
|
||||||
|
}, [highlights, highlightVisibility, settings])
|
||||||
|
|
||||||
|
// Show hero section if we have an image OR a title
|
||||||
|
if (cachedImage || title) {
|
||||||
return (
|
return (
|
||||||
<div className="reader-hero-image">
|
<>
|
||||||
<img src={cachedImage} alt={title || 'Article image'} />
|
<div className="reader-hero-image">
|
||||||
{formattedDate && (
|
{cachedImage ? (
|
||||||
<div className="publish-date-topright">
|
<img src={cachedImage} alt={title || 'Article image'} />
|
||||||
{formattedDate}
|
) : (
|
||||||
</div>
|
<div className="reader-hero-placeholder">
|
||||||
)}
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
{title && (
|
|
||||||
<div className="reader-header-overlay">
|
|
||||||
<h2 className="reader-title">{title}</h2>
|
|
||||||
{summary && <p className="reader-summary">{summary}</p>}
|
|
||||||
<div className="reader-meta">
|
|
||||||
{readingTimeText && (
|
|
||||||
<div className="reading-time">
|
|
||||||
<FontAwesomeIcon icon={faClock} />
|
|
||||||
<span>{readingTimeText}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasHighlights && (
|
|
||||||
<div className="highlight-indicator">
|
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{formattedDate && (
|
||||||
|
<div
|
||||||
|
className="publish-date-topright"
|
||||||
|
style={{
|
||||||
|
color: textColor
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedDate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<div className="reader-header-overlay">
|
||||||
|
<h2 className="reader-title">{title}</h2>
|
||||||
|
{summary && <p className={`reader-summary ${isLongSummary ? 'hide-on-mobile' : ''}`}>{summary}</p>}
|
||||||
|
<div className="reader-meta">
|
||||||
|
{readingTimeText && (
|
||||||
|
<div className="reading-time">
|
||||||
|
<FontAwesomeIcon icon={faClock} />
|
||||||
|
<span>{readingTimeText}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasHighlights && (
|
||||||
|
<div
|
||||||
|
className="highlight-indicator"
|
||||||
|
style={getHighlightIndicatorStyles(true)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLongSummary && (
|
||||||
|
<div className="reader-summary-below-image">
|
||||||
|
<p className="reader-summary">{summary}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +132,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
{title && (
|
{title && (
|
||||||
<div className="reader-header">
|
<div className="reader-header">
|
||||||
{formattedDate && (
|
{formattedDate && (
|
||||||
<div className="publish-date-topright">
|
<div
|
||||||
|
className="publish-date-topright"
|
||||||
|
style={{
|
||||||
|
color: textColor
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -80,7 +151,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<div className="highlight-indicator">
|
<div
|
||||||
|
className="highlight-indicator"
|
||||||
|
style={getHighlightIndicatorStyles(false)}
|
||||||
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
70
src/components/ReadingProgressIndicator.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ReadingProgressIndicatorProps {
|
||||||
|
progress: number // 0 to 100
|
||||||
|
isComplete?: boolean
|
||||||
|
showPercentage?: boolean
|
||||||
|
className?: string
|
||||||
|
isSidebarCollapsed?: boolean
|
||||||
|
isHighlightsCollapsed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> = ({
|
||||||
|
progress,
|
||||||
|
isComplete = false,
|
||||||
|
showPercentage = true,
|
||||||
|
className = '',
|
||||||
|
isSidebarCollapsed = false,
|
||||||
|
isHighlightsCollapsed = false
|
||||||
|
}) => {
|
||||||
|
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||||
|
|
||||||
|
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||||
|
const leftOffset = isSidebarCollapsed
|
||||||
|
? 'var(--sidebar-collapsed-width)'
|
||||||
|
: 'var(--sidebar-width)'
|
||||||
|
const rightOffset = isHighlightsCollapsed
|
||||||
|
? 'var(--highlights-collapsed-width)'
|
||||||
|
: 'var(--highlights-width)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`reading-progress-bar fixed bottom-0 left-0 right-0 z-[1102] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}
|
||||||
|
style={{
|
||||||
|
'--left-offset': leftOffset,
|
||||||
|
'--right-offset': rightOffset,
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)',
|
||||||
|
opacity: 0.95
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-1 h-0.5 rounded-full overflow-hidden relative"
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-300 relative ${
|
||||||
|
isComplete
|
||||||
|
? 'bg-green-500'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${clampedProgress}%`,
|
||||||
|
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showPercentage && (
|
||||||
|
<div
|
||||||
|
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||||
|
isComplete ? 'text-green-500' : ''
|
||||||
|
}`}
|
||||||
|
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
||||||
|
>
|
||||||
|
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
src/components/RefreshIndicator.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
interface RefreshIndicatorProps {
|
||||||
|
isRefreshing: boolean
|
||||||
|
pullPosition: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const THRESHOLD = 80
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple pull-to-refresh visual indicator
|
||||||
|
*/
|
||||||
|
const RefreshIndicator: React.FC<RefreshIndicatorProps> = ({
|
||||||
|
isRefreshing,
|
||||||
|
pullPosition
|
||||||
|
}) => {
|
||||||
|
const isVisible = isRefreshing || pullPosition > 0
|
||||||
|
if (!isVisible) return null
|
||||||
|
|
||||||
|
const opacity = Math.min(pullPosition / THRESHOLD, 1)
|
||||||
|
const translateY = isRefreshing ? THRESHOLD / 3 : pullPosition / 3
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${translateY}px`,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 30,
|
||||||
|
opacity,
|
||||||
|
transition: isRefreshing ? 'opacity 0.2s' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'var(--surface-secondary, #ffffff)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faArrowRotateRight}
|
||||||
|
style={{
|
||||||
|
transform: isRefreshing ? 'none' : `rotate(${pullPosition}deg)`,
|
||||||
|
color: 'var(--accent-color, #3b82f6)'
|
||||||
|
}}
|
||||||
|
className={isRefreshing ? 'fa-spin' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RefreshIndicator
|
||||||
|
|
||||||
@@ -4,15 +4,22 @@ import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-s
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { isLocalRelay } from '../utils/helpers'
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
|
|
||||||
interface RelayStatusIndicatorProps {
|
interface RelayStatusIndicatorProps {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
showOnMobile?: boolean // Control visibility based on scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||||
|
relayPool,
|
||||||
|
showOnMobile = true
|
||||||
|
}) => {
|
||||||
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
||||||
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
||||||
const [isConnecting, setIsConnecting] = useState(true)
|
const [isConnecting, setIsConnecting] = useState(true)
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
if (!relayPool) return null
|
if (!relayPool) return null
|
||||||
|
|
||||||
@@ -52,41 +59,114 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
|||||||
hasRemoteRelay,
|
hasRemoteRelay,
|
||||||
isConnecting
|
isConnecting
|
||||||
})
|
})
|
||||||
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||||
|
|
||||||
// Don't show indicator when fully connected (but show when connecting)
|
// Don't show indicator when fully connected (but show when connecting)
|
||||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setIsExpanded(!isExpanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On mobile, default to collapsed (icon only). On desktop, always show details.
|
||||||
|
const showDetails = !isMobile || isExpanded
|
||||||
|
|
||||||
|
// On mobile when collapsed, make it circular like the highlight button
|
||||||
|
const isCollapsedOnMobile = isMobile && !isExpanded
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relay-status-indicator ${isConnecting ? 'connecting' : ''}`} title={
|
<div
|
||||||
isConnecting
|
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
|
||||||
? 'Connecting to relays...'
|
title={
|
||||||
: offlineMode
|
!isMobile ? (
|
||||||
? 'Offline - No relays connected'
|
isConnecting
|
||||||
: 'Local Relays Only - Highlights will be marked as local'
|
? 'Connecting to relays...'
|
||||||
}>
|
: offlineMode
|
||||||
|
? 'Offline - No relays connected'
|
||||||
|
: 'Local Relays Only - Highlights will be marked as local'
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '32px',
|
||||||
|
left: '32px',
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: showDetails ? '0.5rem' : '0',
|
||||||
|
padding: isCollapsedOnMobile ? '0.875rem' : (showDetails ? '0.75rem 1rem' : '0.75rem'),
|
||||||
|
width: isCollapsedOnMobile ? '56px' : 'auto',
|
||||||
|
height: isCollapsedOnMobile ? '56px' : 'auto',
|
||||||
|
backgroundColor: 'rgba(39, 39, 42, 0.9)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
border: '1px solid rgb(82, 82, 91)',
|
||||||
|
borderRadius: isCollapsedOnMobile ? '50%' : '12px',
|
||||||
|
color: 'rgb(228, 228, 231)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||||
|
cursor: isMobile ? 'pointer' : 'default',
|
||||||
|
opacity: isMobile && !showOnMobile ? 0 : 1,
|
||||||
|
visibility: isMobile && !showOnMobile ? 'hidden' : 'visible',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
userSelect: 'none',
|
||||||
|
justifyContent: isCollapsedOnMobile ? 'center' : 'flex-start'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="relay-status-icon">
|
<div className="relay-status-icon">
|
||||||
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relay-status-text">
|
{showDetails && (
|
||||||
{isConnecting ? (
|
<>
|
||||||
<span className="relay-status-title">Connecting</span>
|
<div
|
||||||
) : offlineMode ? (
|
className="relay-status-text"
|
||||||
<>
|
style={{
|
||||||
<span className="relay-status-title">Offline</span>
|
display: 'flex',
|
||||||
<span className="relay-status-subtitle">No relays connected</span>
|
flexDirection: 'column',
|
||||||
</>
|
gap: '0.125rem'
|
||||||
) : (
|
}}
|
||||||
<>
|
>
|
||||||
<span className="relay-status-title">Flight Mode</span>
|
{isConnecting ? (
|
||||||
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
|
<span className="relay-status-title">Connecting</span>
|
||||||
</>
|
) : offlineMode ? (
|
||||||
)}
|
<>
|
||||||
</div>
|
<span className="relay-status-title">Offline</span>
|
||||||
{!offlineMode && !isConnecting && (
|
<span
|
||||||
<div className="relay-status-pulse">
|
className="relay-status-subtitle"
|
||||||
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
style={{
|
||||||
</div>
|
fontSize: '0.75rem',
|
||||||
|
opacity: 0.7,
|
||||||
|
fontWeight: 400
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No relays connected
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="relay-status-title">Flight Mode</span>
|
||||||
|
<span
|
||||||
|
className="relay-status-subtitle"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
opacity: 0.7,
|
||||||
|
fontWeight: 400
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!offlineMode && !isConnecting && (
|
||||||
|
<div className="relay-status-pulse">
|
||||||
|
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models, Helpers } from 'applesauce-core'
|
import { Models, Helpers } from 'applesauce-core'
|
||||||
import { decode, npubEncode } from 'nostr-tools/nip19'
|
import { decode, npubEncode } from 'nostr-tools/nip19'
|
||||||
@@ -24,14 +25,12 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
|
|||||||
|
|
||||||
if (npub) {
|
if (npub) {
|
||||||
return (
|
return (
|
||||||
<a
|
<Link
|
||||||
href={`https://search.dergigi.com/p/${npub}`}
|
to={`/p/${npub}`}
|
||||||
className="nostr-mention"
|
className="nostr-mention"
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
@{display}
|
@{display}
|
||||||
</a>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { loadFont } from '../utils/fontLoader'
|
import { loadFont } from '../utils/fontLoader'
|
||||||
|
import ThemeSettings from './Settings/ThemeSettings'
|
||||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||||
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
||||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
||||||
import ZapSettings from './Settings/ZapSettings'
|
import ZapSettings from './Settings/ZapSettings'
|
||||||
import OfflineModeSettings from './Settings/OfflineModeSettings'
|
import OfflineModeSettings from './Settings/OfflineModeSettings'
|
||||||
import RelaySettings from './Settings/RelaySettings'
|
import RelaySettings from './Settings/RelaySettings'
|
||||||
|
import PWASettings from './Settings/PWASettings'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: UserSettings = {
|
const DEFAULT_SETTINGS: UserSettings = {
|
||||||
@@ -21,10 +23,10 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
readingFont: 'source-serif-4',
|
readingFont: 'source-serif-4',
|
||||||
fontSize: 21,
|
fontSize: 21,
|
||||||
highlightStyle: 'marker',
|
highlightStyle: 'marker',
|
||||||
highlightColor: '#ffff00',
|
highlightColor: '#fde047',
|
||||||
highlightColorNostrverse: '#9333ea',
|
highlightColorNostrverse: '#9333ea',
|
||||||
highlightColorFriends: '#f97316',
|
highlightColorFriends: '#f97316',
|
||||||
highlightColorMine: '#ffff00',
|
highlightColorMine: '#fde047',
|
||||||
defaultHighlightVisibilityNostrverse: true,
|
defaultHighlightVisibilityNostrverse: true,
|
||||||
defaultHighlightVisibilityFriends: true,
|
defaultHighlightVisibilityFriends: true,
|
||||||
defaultHighlightVisibilityMine: true,
|
defaultHighlightVisibilityMine: true,
|
||||||
@@ -57,7 +59,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
return migrated
|
return migrated
|
||||||
})
|
})
|
||||||
const isInitialMount = useRef(true)
|
const isInitialMount = useRef(true)
|
||||||
const saveTimeoutRef = useRef<number | null>(null)
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const isLocallyUpdating = useRef(false)
|
const isLocallyUpdating = useRef(false)
|
||||||
|
|
||||||
// Poll for relay status updates
|
// Poll for relay status updates
|
||||||
@@ -158,12 +160,14 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
|
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||||
|
<PWASettings />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
62
src/components/Settings/PWASettings.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { usePWAInstall } from '../../hooks/usePWAInstall'
|
||||||
|
|
||||||
|
const PWASettings: React.FC = () => {
|
||||||
|
const { isInstallable, isInstalled, installApp } = usePWAInstall()
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
const success = await installApp()
|
||||||
|
if (success) {
|
||||||
|
console.log('App installed successfully')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInstalled) {
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Progressive Web App</h3>
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-info">
|
||||||
|
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
|
||||||
|
<span>Boris is installed as an app</span>
|
||||||
|
</div>
|
||||||
|
<p className="setting-description">
|
||||||
|
You can launch Boris from your home screen or app drawer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInstallable) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Progressive Web App</h3>
|
||||||
|
<div className="setting-group">
|
||||||
|
<div className="setting-info">
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
|
||||||
|
<span>Install Boris as an app</span>
|
||||||
|
</div>
|
||||||
|
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
Install Boris on your device for a native app experience with offline support.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="zap-preset-btn"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faDownload} />
|
||||||
|
Install App
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PWASettings
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { UserSettings } from '../../services/settingsService'
|
||||||
import IconButton from '../IconButton'
|
import IconButton from '../IconButton'
|
||||||
@@ -73,7 +72,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
<label className="setting-label">My Highlights</label>
|
<label className="setting-label">My Highlights</label>
|
||||||
<div className="setting-control">
|
<div className="setting-control">
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
selectedColor={settings.highlightColorMine || '#ffff00'}
|
selectedColor={settings.highlightColorMine || '#fde047'}
|
||||||
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,33 +101,39 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Default Highlight Visibility</label>
|
<label>Default Highlight Visibility</label>
|
||||||
<div className="highlight-level-toggles">
|
<div className="highlight-level-toggles">
|
||||||
<button
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
|
|
||||||
title="Nostrverse highlights"
|
title="Nostrverse highlights"
|
||||||
aria-label="Toggle nostrverse highlights by default"
|
ariaLabel="Toggle nostrverse highlights by default"
|
||||||
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
variant="ghost"
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faNetworkWired} />
|
color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||||
</button>
|
opacity: (settings.defaultHighlightVisibilityNostrverse !== false) ? 1 : 0.4
|
||||||
<button
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
||||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
|
|
||||||
title="Friends highlights"
|
title="Friends highlights"
|
||||||
aria-label="Toggle friends highlights by default"
|
ariaLabel="Toggle friends highlights by default"
|
||||||
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
variant="ghost"
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faUserGroup} />
|
color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
</button>
|
opacity: (settings.defaultHighlightVisibilityFriends !== false) ? 1 : 0.4
|
||||||
<button
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
||||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
|
|
||||||
title="My highlights"
|
title="My highlights"
|
||||||
aria-label="Toggle my highlights by default"
|
ariaLabel="Toggle my highlights by default"
|
||||||
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
variant="ghost"
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faUser} />
|
color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
</button>
|
opacity: (settings.defaultHighlightVisibilityMine !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -100,13 +100,16 @@ const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{
|
<div
|
||||||
fontSize: '0.9rem',
|
className="relay-url"
|
||||||
fontFamily: 'var(--font-mono, monospace)',
|
style={{
|
||||||
whiteSpace: 'nowrap',
|
fontSize: '0.9rem',
|
||||||
overflow: 'hidden',
|
fontFamily: 'var(--font-mono, monospace)',
|
||||||
textOverflow: 'ellipsis'
|
whiteSpace: 'nowrap',
|
||||||
}}>
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formatRelayUrl(relay.url)}
|
{formatRelayUrl(relay.url)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
107
src/components/Settings/ThemeSettings.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { faSun, faMoon, faDesktop } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
|
type DarkColorTheme = 'black' | 'midnight' | 'charcoal'
|
||||||
|
type LightColorTheme = 'paper-white' | 'sepia' | 'ivory'
|
||||||
|
|
||||||
|
interface ThemeSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
const currentTheme = settings.theme ?? 'system'
|
||||||
|
const currentDarkColor = settings.darkColorTheme ?? 'midnight'
|
||||||
|
const currentLightColor = settings.lightColorTheme ?? 'sepia'
|
||||||
|
|
||||||
|
// Determine which color picker to show based on current theme
|
||||||
|
const showDarkColors = currentTheme === 'dark' || currentTheme === 'system'
|
||||||
|
const showLightColors = currentTheme === 'light' || currentTheme === 'system'
|
||||||
|
|
||||||
|
// Color definitions for swatches
|
||||||
|
const darkColors = {
|
||||||
|
black: '#000000',
|
||||||
|
midnight: '#18181b',
|
||||||
|
charcoal: '#1c1c1e'
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightColors = {
|
||||||
|
'paper-white': '#ffffff',
|
||||||
|
sepia: '#f4f1ea',
|
||||||
|
ivory: '#fffff0'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Theme</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Appearance</label>
|
||||||
|
<div className="setting-buttons">
|
||||||
|
<IconButton
|
||||||
|
icon={faSun}
|
||||||
|
onClick={() => onUpdate({ theme: 'light' })}
|
||||||
|
title="Light theme"
|
||||||
|
ariaLabel="Light theme"
|
||||||
|
variant={currentTheme === 'light' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faMoon}
|
||||||
|
onClick={() => onUpdate({ theme: 'dark' })}
|
||||||
|
title="Dark theme"
|
||||||
|
ariaLabel="Dark theme"
|
||||||
|
variant={currentTheme === 'dark' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faDesktop}
|
||||||
|
onClick={() => onUpdate({ theme: 'system' })}
|
||||||
|
title="Use system preference"
|
||||||
|
ariaLabel="Use system preference"
|
||||||
|
variant={currentTheme === 'system' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDarkColors && (
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Dark Theme</label>
|
||||||
|
<div className="color-picker">
|
||||||
|
{Object.entries(darkColors).map(([key, color]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`color-swatch ${currentDarkColor === key ? 'active' : ''}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => onUpdate({ darkColorTheme: key as DarkColorTheme })}
|
||||||
|
title={key.charAt(0).toUpperCase() + key.slice(1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLightColors && (
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Light Theme</label>
|
||||||
|
<div className="color-picker">
|
||||||
|
{Object.entries(lightColors).map(([key, color]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`color-swatch ${currentLightColor === key ? 'active' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
border: color === '#ffffff' ? '2px solid #e5e7eb' : '1px solid #e5e7eb'
|
||||||
|
}}
|
||||||
|
onClick={() => onUpdate({ lightColorTheme: key as LightColorTheme })}
|
||||||
|
title={key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeSettings
|
||||||
@@ -1,28 +1,22 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
import { Accounts } from 'applesauce-accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import AddBookmarkModal from './AddBookmarkModal'
|
|
||||||
import { createWebBookmark } from '../services/webBookmarkService'
|
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
relayPool: RelayPool | null
|
|
||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
|
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
@@ -36,7 +30,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
accountManager.setActive(account)
|
accountManager.setActive(account)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed:', error)
|
console.error('Login failed:', error)
|
||||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
||||||
} finally {
|
} finally {
|
||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
}
|
}
|
||||||
@@ -54,14 +48,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
|
||||||
if (!activeAccount || !relayPool) {
|
|
||||||
throw new Error('Please login to create bookmarks')
|
|
||||||
}
|
|
||||||
|
|
||||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileImage = getProfileImage()
|
const profileImage = getProfileImage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,8 +76,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
<div
|
<div
|
||||||
className="profile-avatar"
|
className="profile-avatar"
|
||||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
onClick={
|
||||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
activeAccount
|
||||||
|
? () => navigate('/me')
|
||||||
|
: (isConnecting ? () => {} : handleLogin)
|
||||||
|
}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{profileImage ? (
|
{profileImage ? (
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
<img src={profileImage} alt={getUserDisplayName()} />
|
||||||
@@ -120,15 +110,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Settings"
|
ariaLabel="Settings"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
{activeAccount && (
|
|
||||||
<IconButton
|
|
||||||
icon={faPlus}
|
|
||||||
onClick={() => setShowAddModal(true)}
|
|
||||||
title="Add bookmark"
|
|
||||||
ariaLabel="Add bookmark"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeAccount ? (
|
{activeAccount ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRightFromBracket}
|
icon={faRightFromBracket}
|
||||||
@@ -148,12 +129,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showAddModal && (
|
|
||||||
<AddBookmarkModal
|
|
||||||
onClose={() => setShowAddModal(false)}
|
|
||||||
onSave={handleSaveBookmark}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/components/Skeletons/BlogPostSkeleton.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Skeleton from 'react-loading-skeleton'
|
||||||
|
|
||||||
|
export const BlogPostSkeleton: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="blog-post-card"
|
||||||
|
style={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="blog-post-card-image">
|
||||||
|
<Skeleton height={200} style={{ display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
<div className="blog-post-card-content">
|
||||||
|
<Skeleton
|
||||||
|
height={24}
|
||||||
|
width="85%"
|
||||||
|
style={{ marginBottom: '0.75rem' }}
|
||||||
|
className="blog-post-card-title"
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
count={2}
|
||||||
|
style={{ marginBottom: '0.5rem' }}
|
||||||
|
className="blog-post-card-summary"
|
||||||
|
/>
|
||||||
|
<div className="blog-post-card-meta" style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
<span className="blog-post-card-author" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<Skeleton width={100} height={14} />
|
||||||
|
</span>
|
||||||
|
<span className="blog-post-card-date" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<Skeleton width={80} height={14} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
80
src/components/Skeletons/BookmarkSkeleton.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Skeleton from 'react-loading-skeleton'
|
||||||
|
import { ViewMode } from '../Bookmarks'
|
||||||
|
|
||||||
|
interface BookmarkSkeletonProps {
|
||||||
|
viewMode: ViewMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BookmarkSkeleton: React.FC<BookmarkSkeletonProps> = ({ viewMode }) => {
|
||||||
|
if (viewMode === 'compact') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bookmark-item-compact"
|
||||||
|
style={{ padding: '0.75rem', marginBottom: '0.5rem' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start' }}>
|
||||||
|
<Skeleton width={40} height={40} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Skeleton width="80%" height={16} style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<Skeleton width="60%" height={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'cards') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bookmark-card"
|
||||||
|
style={{
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Skeleton height={160} style={{ display: 'block' }} />
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<Skeleton height={20} width="90%" style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton count={2} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||||
|
<Skeleton width={80} height={14} />
|
||||||
|
<Skeleton width={60} height={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// large view
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bookmark-large"
|
||||||
|
style={{
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Skeleton height={240} style={{ display: 'block' }} />
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<Skeleton height={24} width="85%" style={{ marginBottom: '0.75rem' }} />
|
||||||
|
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
|
||||||
|
<Skeleton circle width={32} height={32} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Skeleton width={120} height={14} style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<Skeleton width={100} height={12} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
66
src/components/Skeletons/ContentSkeleton.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Skeleton from 'react-loading-skeleton'
|
||||||
|
|
||||||
|
export const ContentSkeleton: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="reader-content"
|
||||||
|
style={{
|
||||||
|
maxWidth: '900px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '2rem 1rem'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Skeleton
|
||||||
|
height={48}
|
||||||
|
width="90%"
|
||||||
|
style={{ marginBottom: '1rem' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Byline / Meta */}
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', alignItems: 'center' }}>
|
||||||
|
<Skeleton circle width={40} height={40} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Skeleton width={150} height={16} style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<Skeleton width={200} height={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover image */}
|
||||||
|
<Skeleton
|
||||||
|
height={400}
|
||||||
|
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Paragraphs */}
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton width="80%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<Skeleton count={4} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton width="65%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton width="90%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Another image placeholder */}
|
||||||
|
<Skeleton
|
||||||
|
height={300}
|
||||||
|
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton width="75%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
36
src/components/Skeletons/HighlightSkeleton.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Skeleton from 'react-loading-skeleton'
|
||||||
|
|
||||||
|
export const HighlightSkeleton: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="highlight-item"
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Author line with avatar */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||||
|
<Skeleton circle width={24} height={24} />
|
||||||
|
<Skeleton width={120} height={14} />
|
||||||
|
<Skeleton width={60} height={12} style={{ marginLeft: 'auto' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Highlight content */}
|
||||||
|
<div style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<Skeleton count={2} style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<Skeleton width="70%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Citation/context */}
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<Skeleton width="90%" height={12} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
49
src/components/Skeletons/SkeletonThemeProvider.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { SkeletonTheme } from 'react-loading-skeleton'
|
||||||
|
|
||||||
|
interface SkeletonThemeProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SkeletonThemeProvider: React.FC<SkeletonThemeProviderProps> = ({ children }) => {
|
||||||
|
const [colors, setColors] = useState({
|
||||||
|
baseColor: '#27272a',
|
||||||
|
highlightColor: '#52525b'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateColors = () => {
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement)
|
||||||
|
const baseColor = rootStyles.getPropertyValue('--color-bg-elevated').trim() || '#27272a'
|
||||||
|
const highlightColor = rootStyles.getPropertyValue('--color-border-subtle').trim() || '#52525b'
|
||||||
|
|
||||||
|
setColors({ baseColor, highlightColor })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateColors()
|
||||||
|
|
||||||
|
// Watch for theme changes via MutationObserver
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||||
|
updateColors()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class']
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SkeletonTheme baseColor={colors.baseColor} highlightColor={colors.highlightColor}>
|
||||||
|
{children}
|
||||||
|
</SkeletonTheme>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
6
src/components/Skeletons/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { SkeletonThemeProvider } from './SkeletonThemeProvider'
|
||||||
|
export { BookmarkSkeleton } from './BookmarkSkeleton'
|
||||||
|
export { BlogPostSkeleton } from './BlogPostSkeleton'
|
||||||
|
export { HighlightSkeleton } from './HighlightSkeleton'
|
||||||
|
export { ContentSkeleton } from './ContentSkeleton'
|
||||||
|
|
||||||
235
src/components/Support.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||||
|
import { fetchProfiles } from '../services/profileService'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
interface SupportProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
settings: UserSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupporterProfile = ZapSender
|
||||||
|
|
||||||
|
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||||
|
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSupporters = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const zappers = await fetchBorisZappers(relayPool)
|
||||||
|
|
||||||
|
if (zappers.length > 0) {
|
||||||
|
const pubkeys = zappers.map(z => z.pubkey)
|
||||||
|
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSupporters(zappers)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load supporters:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSupporters()
|
||||||
|
}, [relayPool, eventStore, settings])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
||||||
|
<div className="text-center mb-16 md:mb-20">
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<img
|
||||||
|
src="/thank-you.svg"
|
||||||
|
alt="Thank you"
|
||||||
|
className="w-56 h-56 md:w-72 md:h-72 opacity-90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Thank You!
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
Your{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.readwithboris.com/#pricing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
zaps
|
||||||
|
</a>
|
||||||
|
{' '}help keep this project alive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{supporters.length === 0 ? (
|
||||||
|
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Whales Section */}
|
||||||
|
{supporters.filter(s => s.isWhale).length > 0 && (
|
||||||
|
<div className="mb-16 md:mb-20">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Legends
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
|
||||||
|
{supporters.filter(s => s.isWhale).map(supporter => (
|
||||||
|
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={true} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Regular Supporters Section */}
|
||||||
|
{supporters.filter(s => !s.isWhale).length > 0 && (
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Supporters
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
|
||||||
|
{supporters.filter(s => !s.isWhale).map(supporter => (
|
||||||
|
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-16 md:mt-20 pt-8 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<p className="text-base" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
Zap{' '}
|
||||||
|
<a
|
||||||
|
href="https://njump.me/npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
Boris
|
||||||
|
</a>
|
||||||
|
{' '}a{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.readwithboris.com/#pricing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
meaningful amount of sats
|
||||||
|
</a>
|
||||||
|
{' '}and your avatar will show above.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
Total supporters: {supporters.length} •
|
||||||
|
Total zaps: {supporters.reduce((sum, s) => sum + s.zapCount, 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupporterCardProps {
|
||||||
|
supporter: SupporterProfile
|
||||||
|
isWhale: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
|
||||||
|
|
||||||
|
const picture = profile?.picture
|
||||||
|
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const npub = nip19.npubEncode(supporter.pubkey)
|
||||||
|
navigate(`/p/${npub}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className={`rounded-full overflow-hidden flex items-center justify-center cursor-pointer transition-transform hover:scale-105
|
||||||
|
${isWhale ? 'w-24 h-24 md:w-28 md:h-28 ring-4 ring-yellow-400' : 'w-10 h-10 md:w-12 md:h-12'}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)'
|
||||||
|
}}
|
||||||
|
title={`${name} • ${supporter.totalSats.toLocaleString()} sats`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{picture ? (
|
||||||
|
<img
|
||||||
|
src={picture}
|
||||||
|
alt={name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faUserCircle}
|
||||||
|
className={isWhale ? 'text-5xl' : 'text-3xl'}
|
||||||
|
style={{ color: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whale Badge */}
|
||||||
|
{isWhale && (
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
|
||||||
|
style={{ borderColor: 'var(--color-bg)' }}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHeart} className="text-zinc-900 text-sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name and Total */}
|
||||||
|
<div className="mt-2 text-center">
|
||||||
|
<p
|
||||||
|
className={`font-medium truncate max-w-full ${isWhale ? 'text-sm' : 'text-xs'}`}
|
||||||
|
style={{ color: 'var(--color-text)' }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={isWhale ? 'text-xs' : 'text-[10px]'}
|
||||||
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
>
|
||||||
|
{supporter.totalSats.toLocaleString()} sats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Support
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
@@ -19,6 +19,9 @@ import { HighlightVisibility } from './HighlightsPanel'
|
|||||||
import { HighlightButtonRef } from './HighlightButton'
|
import { HighlightButtonRef } from './HighlightButton'
|
||||||
import { BookmarkReference } from '../utils/contentLoader'
|
import { BookmarkReference } from '../utils/contentLoader'
|
||||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
|
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
interface ThreePaneLayoutProps {
|
interface ThreePaneLayoutProps {
|
||||||
// Layout state
|
// Layout state
|
||||||
@@ -27,6 +30,9 @@ interface ThreePaneLayoutProps {
|
|||||||
isSidebarOpen: boolean
|
isSidebarOpen: boolean
|
||||||
showSettings: boolean
|
showSettings: boolean
|
||||||
showExplore?: boolean
|
showExplore?: boolean
|
||||||
|
showMe?: boolean
|
||||||
|
showProfile?: boolean
|
||||||
|
showSupport?: boolean
|
||||||
|
|
||||||
// Bookmarks pane
|
// Bookmarks pane
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -58,6 +64,8 @@ interface ThreePaneLayoutProps {
|
|||||||
onClearSelection: () => void
|
onClearSelection: () => void
|
||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
followedPubkeys: Set<string>
|
followedPubkeys: Set<string>
|
||||||
|
activeAccount?: IAccount | null
|
||||||
|
currentArticle?: NostrEvent | null
|
||||||
|
|
||||||
// Highlights pane
|
// Highlights pane
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
@@ -80,12 +88,50 @@ interface ThreePaneLayoutProps {
|
|||||||
|
|
||||||
// Optional Explore content
|
// Optional Explore content
|
||||||
explore?: React.ReactNode
|
explore?: React.ReactNode
|
||||||
|
|
||||||
|
// Optional Me content
|
||||||
|
me?: React.ReactNode
|
||||||
|
|
||||||
|
// Optional Profile content
|
||||||
|
profile?: React.ReactNode
|
||||||
|
|
||||||
|
// Optional Support content
|
||||||
|
support?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Detect scroll direction and position to hide/show mobile buttons
|
||||||
|
// Only hide on scroll down when viewing article content
|
||||||
|
const isViewingArticle = !!(props.selectedUrl)
|
||||||
|
const scrollDirection = useScrollDirection({
|
||||||
|
threshold: 10,
|
||||||
|
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && isViewingArticle
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track if we're at the top of the page
|
||||||
|
const [isAtTop, setIsAtTop] = useState(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile || !isViewingArticle) return
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsAtTop(window.scrollY <= 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll() // Check initial position
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [isMobile, isViewingArticle])
|
||||||
|
|
||||||
|
// Bookmark button: hide only when scrolling down
|
||||||
|
const showBookmarkButton = scrollDirection !== 'down'
|
||||||
|
// Highlights button: hide when scrolling down OR at the top
|
||||||
|
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
||||||
|
|
||||||
// Lock body scroll when mobile sidebar or highlights is open
|
// Lock body scroll when mobile sidebar or highlights is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,22 +148,24 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
|
|
||||||
// Handle ESC key to close sidebar or highlights
|
// Handle ESC key to close sidebar or highlights
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
|
||||||
|
|
||||||
if (!isMobile) return
|
if (!isMobile) return
|
||||||
if (!props.isSidebarOpen && props.isHighlightsCollapsed) return
|
if (!isSidebarOpen && isHighlightsCollapsed) return
|
||||||
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (props.isSidebarOpen) {
|
if (isSidebarOpen) {
|
||||||
props.onToggleSidebar()
|
onToggleSidebar()
|
||||||
} else if (!props.isHighlightsCollapsed) {
|
} else if (!isHighlightsCollapsed) {
|
||||||
props.onToggleHighlightsPanel()
|
onToggleHighlightsPanel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleEscape)
|
document.addEventListener('keydown', handleEscape)
|
||||||
return () => document.removeEventListener('keydown', handleEscape)
|
return () => document.removeEventListener('keydown', handleEscape)
|
||||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed, props.onToggleSidebar, props.onToggleHighlightsPanel])
|
}, [isMobile, props])
|
||||||
|
|
||||||
// Trap focus in sidebar when open on mobile
|
// Trap focus in sidebar when open on mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -201,34 +249,54 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile bookmark button - only show when viewing article */}
|
{/* Mobile bookmark button - always show except on settings page */}
|
||||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && (
|
||||||
<button
|
<button
|
||||||
className="mobile-hamburger-btn"
|
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||||
|
showBookmarkButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||||
|
left: 'calc(1rem + env(safe-area-inset-left))',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px'
|
||||||
|
}}
|
||||||
onClick={props.onToggleSidebar}
|
onClick={props.onToggleSidebar}
|
||||||
aria-label="Open bookmarks"
|
aria-label="Open bookmarks"
|
||||||
aria-expanded={props.isSidebarOpen}
|
aria-expanded={props.isSidebarOpen}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBookmark} />
|
<FontAwesomeIcon icon={faBookmark} size="sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile highlights button - only show when viewing article */}
|
{/* Mobile highlights button - only show when viewing article content */}
|
||||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && isViewingArticle && (
|
||||||
<button
|
<button
|
||||||
className="mobile-highlights-btn"
|
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||||
|
showHighlightsButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||||
|
right: 'calc(1rem + env(safe-area-inset-right))',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
backgroundColor: `${props.settings.highlightColorMine || '#fde047'}B3`,
|
||||||
|
color: '#000'
|
||||||
|
}}
|
||||||
onClick={props.onToggleHighlightsPanel}
|
onClick={props.onToggleHighlightsPanel}
|
||||||
aria-label="Open highlights"
|
aria-label="Open highlights"
|
||||||
aria-expanded={!props.isHighlightsCollapsed}
|
aria-expanded={!props.isHighlightsCollapsed}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} size="sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile backdrop */}
|
{/* Mobile backdrop */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div
|
<div
|
||||||
className={`mobile-sidebar-backdrop ${(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'visible' : ''}`}
|
className={`fixed inset-0 bg-black/45 z-[999] transition-opacity duration-300 ${
|
||||||
|
(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'block opacity-100' : 'hidden opacity-0'
|
||||||
|
}`}
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -255,11 +323,14 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
lastFetchTime={props.lastFetchTime}
|
lastFetchTime={props.lastFetchTime}
|
||||||
loading={props.bookmarksLoading}
|
loading={props.bookmarksLoading}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
settings={props.settings}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}>
|
<div
|
||||||
|
ref={mainPaneRef}
|
||||||
|
className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}
|
||||||
|
>
|
||||||
{props.showSettings ? (
|
{props.showSettings ? (
|
||||||
<Settings
|
<Settings
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
@@ -272,6 +343,21 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
{props.explore}
|
{props.explore}
|
||||||
</>
|
</>
|
||||||
|
) : props.showMe && props.me ? (
|
||||||
|
// Render Me inside the main pane to keep side panels
|
||||||
|
<>
|
||||||
|
{props.me}
|
||||||
|
</>
|
||||||
|
) : props.showProfile && props.profile ? (
|
||||||
|
// Render Profile inside the main pane to keep side panels
|
||||||
|
<>
|
||||||
|
{props.profile}
|
||||||
|
</>
|
||||||
|
) : props.showSupport && props.support ? (
|
||||||
|
// Render Support inside the main pane to keep side panels
|
||||||
|
<>
|
||||||
|
{props.support}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ContentPanel
|
<ContentPanel
|
||||||
loading={props.readerLoading}
|
loading={props.readerLoading}
|
||||||
@@ -294,6 +380,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
currentUserPubkey={props.currentUserPubkey}
|
currentUserPubkey={props.currentUserPubkey}
|
||||||
followedPubkeys={props.followedPubkeys}
|
followedPubkeys={props.followedPubkeys}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
|
relayPool={props.relayPool}
|
||||||
|
activeAccount={props.activeAccount}
|
||||||
|
currentArticle={props.currentArticle}
|
||||||
|
isSidebarCollapsed={props.isCollapsed}
|
||||||
|
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -319,6 +410,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
followedPubkeys={props.followedPubkeys}
|
followedPubkeys={props.followedPubkeys}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
eventStore={props.eventStore}
|
eventStore={props.eventStore}
|
||||||
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,10 +418,13 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
<HighlightButton
|
<HighlightButton
|
||||||
ref={props.highlightButtonRef}
|
ref={props.highlightButtonRef}
|
||||||
onHighlight={props.onCreateHighlight}
|
onHighlight={props.onCreateHighlight}
|
||||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
highlightColor={props.settings.highlightColorMine || '#ffff00'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<RelayStatusIndicator relayPool={props.relayPool} />
|
<RelayStatusIndicator
|
||||||
|
relayPool={props.relayPool}
|
||||||
|
showOnMobile={showBookmarkButton}
|
||||||
|
/>
|
||||||
{props.toastMessage && (
|
{props.toastMessage && (
|
||||||
<Toast
|
<Toast
|
||||||
message={props.toastMessage}
|
message={props.toastMessage}
|
||||||
|
|||||||
12
src/config/network.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Centralized network configuration for relay queries
|
||||||
|
// Keep timeouts modest for local-first, longer for remote; tweak per use-case
|
||||||
|
|
||||||
|
export const LOCAL_TIMEOUT_MS = 1200
|
||||||
|
export const REMOTE_TIMEOUT_MS = 6000
|
||||||
|
|
||||||
|
// Contacts often need a bit more time on mobile networks
|
||||||
|
export const CONTACTS_REMOTE_TIMEOUT_MS = 9000
|
||||||
|
|
||||||
|
// Future knobs could live here (e.g., max limits per kind)
|
||||||
|
|
||||||
|
|
||||||
34
src/config/nostrGateways.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Nostr gateway URLs for viewing events and profiles on the web
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const NOSTR_GATEWAY = 'https://ants.sh' as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a profile URL on the gateway
|
||||||
|
*/
|
||||||
|
export function getProfileUrl(npub: string): string {
|
||||||
|
return `${NOSTR_GATEWAY}/p/${npub}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an event URL on the gateway
|
||||||
|
*/
|
||||||
|
export function getEventUrl(nevent: string): string {
|
||||||
|
return `${NOSTR_GATEWAY}/e/${nevent}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a general nostr link on the gateway
|
||||||
|
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
||||||
|
*/
|
||||||
|
export function getNostrUrl(identifier: string): string {
|
||||||
|
// Check the prefix to determine if it's a profile or event
|
||||||
|
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
|
||||||
|
return `${NOSTR_GATEWAY}/p/${identifier}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else (note, nevent, naddr) goes to /e/
|
||||||
|
return `${NOSTR_GATEWAY}/e/${identifier}`
|
||||||
|
}
|
||||||
|
|
||||||
90
src/hooks/useAdaptiveTextColor.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FastAverageColor } from 'fast-average-color'
|
||||||
|
|
||||||
|
interface AdaptiveTextColor {
|
||||||
|
textColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to determine optimal text color based on image background
|
||||||
|
* Samples the top-right corner of the image to ensure publication date is readable
|
||||||
|
*
|
||||||
|
* @param imageUrl - The URL of the image to analyze
|
||||||
|
* @returns Object containing textColor for optimal contrast
|
||||||
|
*/
|
||||||
|
export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveTextColor {
|
||||||
|
const [colors, setColors] = useState<AdaptiveTextColor>({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageUrl) {
|
||||||
|
// No image, use default white text
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fac = new FastAverageColor()
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const width = img.naturalWidth
|
||||||
|
const height = img.naturalHeight
|
||||||
|
|
||||||
|
// Sample top-right corner (last 25% width, first 25% height)
|
||||||
|
const color = fac.getColor(img, {
|
||||||
|
left: Math.floor(width * 0.75),
|
||||||
|
top: 0,
|
||||||
|
width: Math.floor(width * 0.25),
|
||||||
|
height: Math.floor(height * 0.25)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Adaptive color detected:', {
|
||||||
|
hex: color.hex,
|
||||||
|
rgb: color.rgb,
|
||||||
|
isLight: color.isLight,
|
||||||
|
isDark: color.isDark
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use library's built-in isLight check for optimal contrast
|
||||||
|
if (color.isLight) {
|
||||||
|
console.log('Light background detected, using black text')
|
||||||
|
setColors({
|
||||||
|
textColor: '#000000'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log('Dark background detected, using white text')
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to default on error
|
||||||
|
console.error('Error analyzing image color:', error)
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
// Fallback to default if image fails to load
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
img.src = imageUrl
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
fac.destroy()
|
||||||
|
}
|
||||||
|
}, [imageUrl])
|
||||||
|
|
||||||
|
return colors
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IAccount, AccountManager } from 'applesauce-accounts'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
@@ -9,10 +10,8 @@ import { UserSettings } from '../services/settingsService'
|
|||||||
|
|
||||||
interface UseBookmarksDataParams {
|
interface UseBookmarksDataParams {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
activeAccount: IAccount | undefined
|
||||||
activeAccount: any
|
accountManager: AccountManager
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
accountManager: any
|
|
||||||
naddr?: string
|
naddr?: string
|
||||||
currentArticleCoordinate?: string
|
currentArticleCoordinate?: string
|
||||||
currentArticleEventId?: string
|
currentArticleEventId?: string
|
||||||
@@ -44,10 +43,14 @@ export const useBookmarksData = ({
|
|||||||
|
|
||||||
const handleFetchBookmarks = useCallback(async () => {
|
const handleFetchBookmarks = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
|
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
|
||||||
setBookmarksLoading(true)
|
setBookmarksLoading(true)
|
||||||
try {
|
try {
|
||||||
const fullAccount = accountManager.getActive()
|
const fullAccount = accountManager.getActive()
|
||||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
|
// merge-friendly: updater form that preserves visible list until replacement
|
||||||
|
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
|
||||||
|
setBookmarks(() => next)
|
||||||
|
}, settings)
|
||||||
} finally {
|
} finally {
|
||||||
setBookmarksLoading(false)
|
setBookmarksLoading(false)
|
||||||
}
|
}
|
||||||
@@ -102,15 +105,21 @@ export const useBookmarksData = ({
|
|||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data (avoid clearing on route-only changes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
|
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
|
||||||
handleFetchBookmarks()
|
handleFetchBookmarks()
|
||||||
|
}, [relayPool, activeAccount, handleFetchBookmarks])
|
||||||
|
|
||||||
|
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relayPool || !activeAccount) return
|
||||||
if (!naddr) {
|
if (!naddr) {
|
||||||
handleFetchHighlights()
|
handleFetchHighlights()
|
||||||
}
|
}
|
||||||
handleFetchContacts()
|
handleFetchContacts()
|
||||||
}, [relayPool, activeAccount?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
@@ -47,9 +47,9 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
|||||||
})
|
})
|
||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = useCallback(() => {
|
||||||
setIsSidebarOpen(prev => !prev)
|
setIsSidebarOpen(prev => !prev)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isMobile,
|
isMobile,
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ import { fetchReadableContent, ReadableContent } from '../services/readerService
|
|||||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
|
||||||
|
// Helper to extract filename from URL
|
||||||
|
function getFilenameFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const pathname = urlObj.pathname
|
||||||
|
const filename = pathname.substring(pathname.lastIndexOf('/') + 1)
|
||||||
|
// Decode URI component to handle special characters
|
||||||
|
return decodeURIComponent(filename) || url
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface UseExternalUrlLoaderProps {
|
interface UseExternalUrlLoaderProps {
|
||||||
url: string | undefined
|
url: string | undefined
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
@@ -11,7 +24,7 @@ interface UseExternalUrlLoaderProps {
|
|||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
setIsCollapsed: (collapsed: boolean) => void
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
setHighlights: (highlights: Highlight[]) => void
|
setHighlights: (highlights: Highlight[] | ((prev: Highlight[]) => Highlight[])) => void
|
||||||
setHighlightsLoading: (loading: boolean) => void
|
setHighlightsLoading: (loading: boolean) => void
|
||||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||||
setCurrentArticleEventId: (id: string | undefined) => void
|
setCurrentArticleEventId: (id: string | undefined) => void
|
||||||
@@ -57,7 +70,21 @@ export function useExternalUrlLoader({
|
|||||||
|
|
||||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||||
if (typeof fetchHighlightsForUrl === 'function') {
|
if (typeof fetchHighlightsForUrl === 'function') {
|
||||||
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
|
const seen = new Set<string>()
|
||||||
|
const highlightsList = await fetchHighlightsForUrl(
|
||||||
|
relayPool,
|
||||||
|
url,
|
||||||
|
(highlight) => {
|
||||||
|
if (seen.has(highlight.id)) return
|
||||||
|
seen.add(highlight.id)
|
||||||
|
setHighlights((prev) => {
|
||||||
|
if (prev.some(h => h.id === highlight.id)) return prev
|
||||||
|
const next = [...prev, highlight]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Ensure final list is sorted and contains all items
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
||||||
} else {
|
} else {
|
||||||
@@ -70,8 +97,10 @@ export function useExternalUrlLoader({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load external URL:', err)
|
console.error('Failed to load external URL:', err)
|
||||||
|
// For videos and other media files, use the filename as the title
|
||||||
|
const filename = getFilenameFromUrl(url)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: 'Error Loading Content',
|
title: filename,
|
||||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
url
|
url
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useRef } from 'react'
|
import { useCallback, useRef } from 'react'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { ReadableContent } from '../services/readerService'
|
import { ReadableContent } from '../services/readerService'
|
||||||
import { createHighlight } from '../services/highlightCreationService'
|
import { createHighlight } from '../services/highlightCreationService'
|
||||||
@@ -9,8 +11,7 @@ import { HighlightButtonRef } from '../components/HighlightButton'
|
|||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface UseHighlightCreationParams {
|
interface UseHighlightCreationParams {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
activeAccount: IAccount | undefined
|
||||||
activeAccount: any
|
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
eventStore: IEventStore | null
|
eventStore: IEventStore | null
|
||||||
currentArticle: NostrEvent | undefined
|
currentArticle: NostrEvent | undefined
|
||||||
@@ -77,8 +78,18 @@ export const useHighlightCreation = ({
|
|||||||
publishedRelays: newHighlight.publishedRelays
|
publishedRelays: newHighlight.publishedRelays
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Clear the browser's text selection immediately to allow DOM update
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection) {
|
||||||
|
selection.removeAllRanges()
|
||||||
|
}
|
||||||
|
|
||||||
highlightButtonRef.current?.clearSelection()
|
highlightButtonRef.current?.clearSelection()
|
||||||
onHighlightCreated(newHighlight)
|
|
||||||
|
// Force synchronous render to show highlight immediately
|
||||||
|
flushSync(() => {
|
||||||
|
onHighlightCreated(newHighlight)
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to create highlight:', error)
|
console.error('❌ Failed to create highlight:', error)
|
||||||
// Re-throw to allow parent to handle
|
// Re-throw to allow parent to handle
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback, useRef } from 'react'
|
import { useEffect, useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface UseHighlightInteractionsParams {
|
interface UseHighlightInteractionsParams {
|
||||||
onHighlightClick?: (highlightId: string) => void
|
onHighlightClick?: (highlightId: string) => void
|
||||||
@@ -14,6 +14,25 @@ export const useHighlightInteractions = ({
|
|||||||
onClearSelection
|
onClearSelection
|
||||||
}: UseHighlightInteractionsParams) => {
|
}: UseHighlightInteractionsParams) => {
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [contentVersion, setContentVersion] = useState(0)
|
||||||
|
|
||||||
|
// Watch for DOM changes (highlights being added/removed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentRef.current) return
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
// Increment version to trigger re-attachment of handlers
|
||||||
|
setContentVersion(prev => prev + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(contentRef.current, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: false
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Attach click handlers to highlight marks
|
// Attach click handlers to highlight marks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,27 +56,45 @@ export const useHighlightInteractions = ({
|
|||||||
mark.removeEventListener('click', handler)
|
mark.removeEventListener('click', handler)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [onHighlightClick])
|
}, [onHighlightClick, contentVersion])
|
||||||
|
|
||||||
// Scroll to selected highlight
|
// Scroll to selected highlight with retry mechanism
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedHighlightId || !contentRef.current) return
|
if (!selectedHighlightId || !contentRef.current) return
|
||||||
|
|
||||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
let attempts = 0
|
||||||
|
const maxAttempts = 20 // Try for up to 2 seconds
|
||||||
|
const retryDelay = 100
|
||||||
|
|
||||||
if (markElement) {
|
const tryScroll = () => {
|
||||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
if (!contentRef.current) return
|
||||||
|
|
||||||
const htmlElement = markElement as HTMLElement
|
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||||
setTimeout(() => {
|
|
||||||
htmlElement.classList.add('highlight-pulse')
|
if (markElement) {
|
||||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}, 500)
|
|
||||||
|
const htmlElement = markElement as HTMLElement
|
||||||
|
setTimeout(() => {
|
||||||
|
htmlElement.classList.add('highlight-pulse')
|
||||||
|
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||||
|
}, 500)
|
||||||
|
} else if (attempts < maxAttempts) {
|
||||||
|
attempts++
|
||||||
|
setTimeout(tryScroll, retryDelay)
|
||||||
|
} else {
|
||||||
|
console.warn('Could not find mark element for highlight after', maxAttempts, 'attempts:', selectedHighlightId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [selectedHighlightId])
|
|
||||||
|
// Start trying after a small initial delay
|
||||||
|
const timeoutId = setTimeout(tryScroll, 100)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [selectedHighlightId, contentVersion])
|
||||||
|
|
||||||
// Handle text selection
|
// Handle text selection (works for both mouse and touch)
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleSelectionEnd = useCallback(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || selection.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
@@ -76,6 +113,6 @@ export const useHighlightInteractions = ({
|
|||||||
}, 10)
|
}, 10)
|
||||||
}, [onTextSelection, onClearSelection])
|
}, [onTextSelection, onClearSelection])
|
||||||
|
|
||||||
return { contentRef, handleMouseUp }
|
return { contentRef, handleSelectionEnd }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { UserSettings } from '../services/settingsService'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to return image URL for display
|
* Hook to return image URL for display
|
||||||
* Service Worker handles all caching transparently
|
* Service Worker handles all caching transparently
|
||||||
@@ -9,9 +7,7 @@ import { UserSettings } from '../services/settingsService'
|
|||||||
* @returns The image URL (Service Worker handles caching)
|
* @returns The image URL (Service Worker handles caching)
|
||||||
*/
|
*/
|
||||||
export function useImageCache(
|
export function useImageCache(
|
||||||
imageUrl: string | undefined,
|
imageUrl: string | undefined
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
_settings?: UserSettings
|
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
// Service Worker handles everything - just return the URL as-is
|
// Service Worker handles everything - just return the URL as-is
|
||||||
return imageUrl
|
return imageUrl
|
||||||
@@ -22,9 +18,7 @@ export function useImageCache(
|
|||||||
* Triggers a fetch so the SW can cache it even if not visible yet
|
* Triggers a fetch so the SW can cache it even if not visible yet
|
||||||
*/
|
*/
|
||||||
export function useCacheImageOnLoad(
|
export function useCacheImageOnLoad(
|
||||||
imageUrl: string | undefined,
|
imageUrl: string | undefined
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
_settings?: UserSettings
|
|
||||||
): void {
|
): void {
|
||||||
// Service Worker will cache on first fetch
|
// Service Worker will cache on first fetch
|
||||||
// This hook is now a no-op, kept for API compatibility
|
// This hook is now a no-op, kept for API compatibility
|
||||||
|
|||||||
@@ -1,34 +1,86 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
|
||||||
|
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
||||||
|
* Also processes nostr: URIs in the markdown and resolves article titles
|
||||||
*/
|
*/
|
||||||
export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, previewRef: React.RefObject<HTMLDivElement> } => {
|
export const useMarkdownToHTML = (
|
||||||
|
markdown?: string,
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
): {
|
||||||
|
renderedHtml: string
|
||||||
|
previewRef: React.RefObject<HTMLDivElement>
|
||||||
|
processedMarkdown: string
|
||||||
|
} => {
|
||||||
const previewRef = useRef<HTMLDivElement>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||||
|
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!markdown) {
|
if (!markdown) {
|
||||||
setRenderedHtml('')
|
setRenderedHtml('')
|
||||||
|
setProcessedMarkdown('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📝 Converting markdown to HTML...')
|
let isCancelled = false
|
||||||
|
|
||||||
const rafId = requestAnimationFrame(() => {
|
const processMarkdown = async () => {
|
||||||
if (previewRef.current) {
|
// Extract all naddr references
|
||||||
const html = previewRef.current.innerHTML
|
const naddrs = extractNaddrUris(markdown)
|
||||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
|
||||||
setRenderedHtml(html)
|
let processed: string
|
||||||
|
|
||||||
|
if (naddrs.length > 0 && relayPool) {
|
||||||
|
// Fetch article titles for all naddrs
|
||||||
|
try {
|
||||||
|
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
|
||||||
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
|
// Replace nostr URIs with resolved titles
|
||||||
|
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||||
|
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch article titles:', error)
|
||||||
|
// Fall back to basic replacement
|
||||||
|
processed = replaceNostrUrisInMarkdown(markdown)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
// No articles to resolve, use basic replacement
|
||||||
|
processed = replaceNostrUrisInMarkdown(markdown)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
|
setProcessedMarkdown(processed)
|
||||||
|
|
||||||
return () => cancelAnimationFrame(rafId)
|
console.log('📝 Converting markdown to HTML...')
|
||||||
}, [markdown])
|
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
if (previewRef.current && !isCancelled) {
|
||||||
|
const html = previewRef.current.innerHTML
|
||||||
|
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||||
|
setRenderedHtml(html)
|
||||||
|
} else if (!isCancelled) {
|
||||||
|
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return { renderedHtml, previewRef }
|
return () => cancelAnimationFrame(rafId)
|
||||||
|
}
|
||||||
|
|
||||||
|
processMarkdown()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
}, [markdown, relayPool])
|
||||||
|
|
||||||
|
return { renderedHtml, previewRef, processedMarkdown }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef
|
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef
|
||||||
|
|||||||
28
src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useOnlineStatus() {
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => {
|
||||||
|
console.log('🌐 Back online')
|
||||||
|
setIsOnline(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
console.log('📴 Gone offline')
|
||||||
|
setIsOnline(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return isOnline
|
||||||
|
}
|
||||||
|
|
||||||
74
src/hooks/usePWAInstall.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePWAInstall() {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
||||||
|
const [isInstallable, setIsInstallable] = useState(false)
|
||||||
|
const [isInstalled, setIsInstalled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if app is already installed
|
||||||
|
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
setIsInstalled(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for the beforeinstallprompt event
|
||||||
|
const handleBeforeInstallPrompt = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const installPromptEvent = e as BeforeInstallPromptEvent
|
||||||
|
setDeferredPrompt(installPromptEvent)
|
||||||
|
setIsInstallable(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for successful installation
|
||||||
|
const handleAppInstalled = () => {
|
||||||
|
setIsInstalled(true)
|
||||||
|
setIsInstallable(false)
|
||||||
|
setDeferredPrompt(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||||
|
window.addEventListener('appinstalled', handleAppInstalled)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||||
|
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const installApp = async () => {
|
||||||
|
if (!deferredPrompt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deferredPrompt.prompt()
|
||||||
|
const choiceResult = await deferredPrompt.userChoice
|
||||||
|
|
||||||
|
if (choiceResult.outcome === 'accepted') {
|
||||||
|
console.log('✅ PWA installed')
|
||||||
|
setIsInstallable(false)
|
||||||
|
setDeferredPrompt(null)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
console.log('❌ PWA installation dismissed')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error installing PWA:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isInstallable,
|
||||||
|
isInstalled,
|
||||||
|
installApp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
73
src/hooks/useReadingPosition.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface UseReadingPositionOptions {
|
||||||
|
enabled?: boolean
|
||||||
|
onPositionChange?: (position: number) => void
|
||||||
|
onReadingComplete?: () => void
|
||||||
|
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReadingPosition = ({
|
||||||
|
enabled = true,
|
||||||
|
onPositionChange,
|
||||||
|
onReadingComplete,
|
||||||
|
readingCompleteThreshold = 0.9
|
||||||
|
}: UseReadingPositionOptions = {}) => {
|
||||||
|
const [position, setPosition] = useState(0)
|
||||||
|
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||||
|
const hasTriggeredComplete = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
// Get the main content area (reader content)
|
||||||
|
const readerContent = document.querySelector('.reader-html, .reader-markdown')
|
||||||
|
if (!readerContent) return
|
||||||
|
|
||||||
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
|
|
||||||
|
// Calculate position based on how much of the content has been scrolled through
|
||||||
|
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
|
||||||
|
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
|
||||||
|
|
||||||
|
setPosition(clampedProgress)
|
||||||
|
onPositionChange?.(clampedProgress)
|
||||||
|
|
||||||
|
// Check if reading is complete
|
||||||
|
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||||
|
setIsReadingComplete(true)
|
||||||
|
hasTriggeredComplete.current = true
|
||||||
|
onReadingComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial calculation
|
||||||
|
handleScroll()
|
||||||
|
|
||||||
|
// Add scroll listener
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
window.addEventListener('resize', handleScroll, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
window.removeEventListener('resize', handleScroll)
|
||||||
|
}
|
||||||
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
||||||
|
|
||||||
|
// Reset reading complete state when enabled changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setIsReadingComplete(false)
|
||||||
|
hasTriggeredComplete.current = false
|
||||||
|
}
|
||||||
|
}, [enabled])
|
||||||
|
|
||||||
|
return {
|
||||||
|
position,
|
||||||
|
isReadingComplete,
|
||||||
|
progressPercentage: Math.round(position * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/hooks/useScrollDirection.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useState, useEffect, RefObject } from 'react'
|
||||||
|
|
||||||
|
export type ScrollDirection = 'up' | 'down' | 'none'
|
||||||
|
|
||||||
|
interface UseScrollDirectionOptions {
|
||||||
|
threshold?: number
|
||||||
|
enabled?: boolean
|
||||||
|
elementRef?: RefObject<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect scroll direction on window or a specific element
|
||||||
|
* @param options Configuration options
|
||||||
|
* @param options.threshold Minimum scroll distance to trigger direction change (default: 10)
|
||||||
|
* @param options.enabled Whether scroll detection is enabled (default: true)
|
||||||
|
* @param options.elementRef Optional ref to a scrollable element (uses window if not provided)
|
||||||
|
* @returns Current scroll direction ('up', 'down', or 'none')
|
||||||
|
*/
|
||||||
|
export function useScrollDirection({
|
||||||
|
threshold = 10,
|
||||||
|
enabled = true,
|
||||||
|
elementRef
|
||||||
|
}: UseScrollDirectionOptions = {}): ScrollDirection {
|
||||||
|
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('none')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const scrollElement = elementRef?.current || window
|
||||||
|
const getScrollY = () => {
|
||||||
|
if (elementRef?.current) {
|
||||||
|
return elementRef.current.scrollTop
|
||||||
|
}
|
||||||
|
return window.scrollY
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastScrollY = getScrollY()
|
||||||
|
let ticking = false
|
||||||
|
|
||||||
|
const updateScrollDirection = () => {
|
||||||
|
const scrollY = getScrollY()
|
||||||
|
|
||||||
|
// Only update if scroll distance exceeds threshold
|
||||||
|
if (Math.abs(scrollY - lastScrollY) < threshold) {
|
||||||
|
ticking = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setScrollDirection(scrollY > lastScrollY ? 'down' : 'up')
|
||||||
|
lastScrollY = scrollY > 0 ? scrollY : 0
|
||||||
|
ticking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(updateScrollDirection)
|
||||||
|
ticking = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollElement.addEventListener('scroll', onScroll)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollElement.removeEventListener('scroll', onScroll)
|
||||||
|
}
|
||||||
|
}, [threshold, enabled, elementRef])
|
||||||
|
|
||||||
|
return scrollDirection
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ import { EventFactory } from 'applesauce-factory'
|
|||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager } from 'applesauce-accounts'
|
||||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
||||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||||
|
import { applyTheme } from '../utils/theme'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
|
|
||||||
interface UseSettingsParams {
|
interface UseSettingsParams {
|
||||||
@@ -47,7 +48,14 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
const root = document.documentElement.style
|
const root = document.documentElement.style
|
||||||
const fontKey = settings.readingFont || 'system'
|
const fontKey = settings.readingFont || 'system'
|
||||||
|
|
||||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
|
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
|
||||||
|
|
||||||
|
// Apply theme with color variants (defaults to 'system' if not set)
|
||||||
|
applyTheme(
|
||||||
|
settings.theme ?? 'system',
|
||||||
|
settings.darkColorTheme ?? 'midnight',
|
||||||
|
settings.lightColorTheme ?? 'sepia'
|
||||||
|
)
|
||||||
|
|
||||||
// Load font first and wait for it to be ready
|
// Load font first and wait for it to be ready
|
||||||
if (fontKey !== 'system') {
|
if (fontKey !== 'system') {
|
||||||
@@ -61,7 +69,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
root.setProperty('--reading-font-size', `${settings.fontSize || 21}px`)
|
root.setProperty('--reading-font-size', `${settings.fontSize || 21}px`)
|
||||||
|
|
||||||
// Set highlight colors for three levels
|
// Set highlight colors for three levels
|
||||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#fde047')
|
||||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||||
|
|
||||||
@@ -77,7 +85,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
const fullAccount = accountManager.getActive()
|
const fullAccount = accountManager.getActive()
|
||||||
if (!fullAccount) throw new Error('No active account')
|
if (!fullAccount) throw new Error('No active account')
|
||||||
const factory = new EventFactory({ signer: fullAccount })
|
const factory = new EventFactory({ signer: fullAccount })
|
||||||
await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS)
|
await saveSettings(relayPool, eventStore, factory, newSettings)
|
||||||
setSettings(newSettings)
|
setSettings(newSettings)
|
||||||
setToastType('success')
|
setToastType('success')
|
||||||
setToastMessage('Settings saved')
|
setToastMessage('Settings saved')
|
||||||
|
|||||||
1
src/icons/books.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M510.354 435.363L402.686 35.422C396.939 14.078 377.547 0 356.354 0C352.242 0 348.059 0.531 343.896 1.641L282.078 18.125C276.193 19.695 270.939 22.383 266.295 25.758C258.254 10.508 242.436 0 224 0H160C151.213 0 143.084 2.531 136 6.656C128.916 2.531 120.787 0 112 0H48C21.49 0 0 21.492 0 48V464C0 490.508 21.49 512 48 512H112C120.787 512 128.916 509.469 136 505.344C143.084 509.469 151.213 512 160 512H224C250.51 512 272 490.508 272 464V165.281L355.805 476.578C361.553 497.926 380.945 512 402.139 512C406.25 512 410.432 511.469 414.594 510.359L476.412 493.875C502.018 487.043 517.215 460.848 510.354 435.363ZM224 48V96H160V48H224ZM160 144H224V368H160V144ZM112 368H48V144H112V368ZM112 48V96H48V48H112ZM48 464V416H112V464H48ZM160 464V416H224V464H160ZM294.445 64.504L356.271 48.02L356.361 48L368.742 93.93L306.828 110.445L294.445 64.504ZM319.266 156.586L381.18 140.074L439.223 355.41L377.309 371.922L319.266 156.586ZM402.154 464.102L389.746 418.066L451.66 401.555L464.045 447.496L402.154 464.102Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
52
src/icons/customIcons.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { IconDefinition, IconPrefix, IconName } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
import booksSvg from './books.svg?raw'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom icon definitions for FontAwesome Pro icons
|
||||||
|
* or any custom SVG icons that aren't in the free tier
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parseSvgToIconDefinition(svg: string, options: { prefix: IconPrefix, iconName: IconName, unicode?: string }): IconDefinition {
|
||||||
|
const { prefix, iconName, unicode = 'e002' } = options
|
||||||
|
|
||||||
|
// Extract viewBox first; fallback to width/height
|
||||||
|
const viewBoxMatch = svg.match(/viewBox\s*=\s*"([^"]+)"/i)
|
||||||
|
let width = 512
|
||||||
|
let height = 512
|
||||||
|
if (viewBoxMatch) {
|
||||||
|
const parts = viewBoxMatch[1].trim().split(/\s+/)
|
||||||
|
if (parts.length === 4) {
|
||||||
|
const w = Number(parts[2])
|
||||||
|
const h = Number(parts[3])
|
||||||
|
if (!Number.isNaN(w)) width = Math.round(w)
|
||||||
|
if (!Number.isNaN(h)) height = Math.round(h)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const widthMatch = svg.match(/\bwidth\s*=\s*"(\d+(?:\.\d+)?)"/i)
|
||||||
|
const heightMatch = svg.match(/\bheight\s*=\s*"(\d+(?:\.\d+)?)"/i)
|
||||||
|
if (widthMatch) width = Math.round(Number(widthMatch[1]))
|
||||||
|
if (heightMatch) height = Math.round(Number(heightMatch[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all path d attributes
|
||||||
|
const pathDs: string[] = []
|
||||||
|
const pathRegex = /<path[^>]*\sd=\s*"([^"]+)"[^>]*>/gi
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = pathRegex.exec(svg)) !== null) {
|
||||||
|
pathDs.push(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathData = pathDs.length <= 1 ? (pathDs[0] || '') : pathDs
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefix,
|
||||||
|
iconName,
|
||||||
|
icon: [width, height, [], unicode, pathData]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const faBooks: IconDefinition = parseSvgToIconDefinition(booksSvg, {
|
||||||
|
prefix: 'far',
|
||||||
|
iconName: 'books'
|
||||||
|
})
|
||||||
|
|
||||||
3084
src/index.css
22
src/main.tsx
@@ -1,23 +1,35 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import './styles/tailwind.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import 'react-loading-skeleton/dist/skeleton.css'
|
||||||
|
|
||||||
// Register Service Worker for offline image caching
|
// Register Service Worker for PWA functionality
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register('/sw.js')
|
.register('/sw.js', { type: 'module' })
|
||||||
.then(registration => {
|
.then(registration => {
|
||||||
console.log('✅ Service Worker registered:', registration.scope)
|
console.log('✅ Service Worker registered:', registration.scope)
|
||||||
|
|
||||||
// Update service worker when a new version is available
|
// Check for updates periodically
|
||||||
|
setInterval(() => {
|
||||||
|
registration.update()
|
||||||
|
}, 60 * 60 * 1000) // Check every hour
|
||||||
|
|
||||||
|
// Handle service worker updates
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
const newWorker = registration.installing
|
const newWorker = registration.installing
|
||||||
if (newWorker) {
|
if (newWorker) {
|
||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
if (newWorker.state === 'activated') {
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
console.log('🔄 Service Worker updated, page may need reload')
|
// New service worker available
|
||||||
|
console.log('🔄 New version available! Reload to update.')
|
||||||
|
|
||||||
|
// Optionally show a toast notification
|
||||||
|
const updateAvailable = new CustomEvent('sw-update-available')
|
||||||
|
window.dispatchEvent(updateAvailable)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
import { lastValueFrom, take } from 'rxjs'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { AddressPointer } from 'nostr-tools/nip19'
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
||||||
|
import { merge, toArray as rxToArray } from 'rxjs'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
import { rebroadcastEvents } from './rebroadcastService'
|
import { rebroadcastEvents } from './rebroadcastService'
|
||||||
|
|
||||||
@@ -98,9 +100,11 @@ export async function fetchArticleByNaddr(
|
|||||||
const pointer = decoded.data as AddressPointer
|
const pointer = decoded.data as AddressPointer
|
||||||
|
|
||||||
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
|
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
|
||||||
const relays = pointer.relays && pointer.relays.length > 0
|
const baseRelays = pointer.relays && pointer.relays.length > 0
|
||||||
? pointer.relays
|
? pointer.relays
|
||||||
: RELAYS
|
: RELAYS
|
||||||
|
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
||||||
|
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||||
|
|
||||||
// Fetch the article event
|
// Fetch the article event
|
||||||
const filter = {
|
const filter = {
|
||||||
@@ -109,12 +113,10 @@ export async function fetchArticleByNaddr(
|
|||||||
'#d': [pointer.identifier]
|
'#d': [pointer.identifier]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use applesauce relay pool pattern
|
// Parallel local+remote, stream immediate, collect up to first from each
|
||||||
const events = await lastValueFrom(
|
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
|
||||||
relayPool
|
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
|
||||||
.req(relays, filter)
|
const events = collected as NostrEvent[]
|
||||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
|
||||||
)
|
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
throw new Error('Article not found')
|
throw new Error('Article not found')
|
||||||
|
|||||||
91
src/services/articleTitleResolver.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { lastValueFrom, take } from 'rxjs'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
||||||
|
import { merge, toArray as rxToArray } from 'rxjs'
|
||||||
|
|
||||||
|
const { getArticleTitle } = Helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch article title for a single naddr
|
||||||
|
* Returns the title or null if not found/error
|
||||||
|
*/
|
||||||
|
export async function fetchArticleTitle(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
naddr: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
|
||||||
|
if (decoded.type !== 'naddr') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
|
||||||
|
// Define relays to query
|
||||||
|
const baseRelays = pointer.relays && pointer.relays.length > 0
|
||||||
|
? pointer.relays
|
||||||
|
: RELAYS
|
||||||
|
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
||||||
|
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||||
|
|
||||||
|
// Fetch the article event
|
||||||
|
const filter = {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parallel local+remote: collect up to one event from each
|
||||||
|
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 5000)
|
||||||
|
const events = await lastValueFrom(
|
||||||
|
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
|
||||||
|
) as unknown as { created_at: number }[]
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by created_at and take the most recent
|
||||||
|
events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
const article = events[0] as unknown as Parameters<typeof getArticleTitle>[0]
|
||||||
|
|
||||||
|
return getArticleTitle(article) || null
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch article title for', naddr, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch titles for multiple naddrs in parallel
|
||||||
|
* Returns a map of naddr -> title
|
||||||
|
*/
|
||||||
|
export async function fetchArticleTitles(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
naddrs: string[]
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const titleMap = new Map<string, string>()
|
||||||
|
|
||||||
|
// Fetch all titles in parallel
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
naddrs.map(async (naddr) => {
|
||||||
|
const title = await fetchArticleTitle(relayPool, naddr)
|
||||||
|
return { naddr, title }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Process results
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'fulfilled' && result.value.title) {
|
||||||
|
titleMap.set(result.value.naddr, result.value.title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return titleMap
|
||||||
|
}
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
|||||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
const webBookmarks = unique.filter(e => e.kind === 39701)
|
||||||
|
|
||||||
const bookmarkLists = unique
|
const bookmarkLists = unique
|
||||||
.filter(e => e.kind === 10003 || e.kind === 30001)
|
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
||||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||||
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,24 @@ export interface BookmarkData {
|
|||||||
tags?: string[][]
|
tags?: string[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddressPointer {
|
||||||
|
kind: number
|
||||||
|
pubkey: string
|
||||||
|
identifier: string
|
||||||
|
relays?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventPointer {
|
||||||
|
id: string
|
||||||
|
relays?: string[]
|
||||||
|
author?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApplesauceBookmarks {
|
export interface ApplesauceBookmarks {
|
||||||
notes?: BookmarkData[]
|
notes?: EventPointer[]
|
||||||
articles?: BookmarkData[]
|
articles?: AddressPointer[]
|
||||||
hashtags?: BookmarkData[]
|
hashtags?: string[]
|
||||||
urls?: BookmarkData[]
|
urls?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountWithExtension {
|
export interface AccountWithExtension {
|
||||||
@@ -55,13 +68,90 @@ export const processApplesauceBookmarks = (
|
|||||||
|
|
||||||
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
|
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
|
||||||
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
|
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
|
||||||
const allItems: BookmarkData[] = []
|
const allItems: IndividualBookmark[] = []
|
||||||
if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes)
|
|
||||||
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
|
// Process notes (EventPointer[])
|
||||||
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
|
if (applesauceBookmarks.notes) {
|
||||||
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
|
applesauceBookmarks.notes.forEach((note: EventPointer) => {
|
||||||
return allItems.map((bookmark: BookmarkData) => ({
|
allItems.push({
|
||||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
id: note.id,
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: note.author || activeAccount.pubkey,
|
||||||
|
kind: 1, // Short note kind
|
||||||
|
tags: [],
|
||||||
|
parsedContent: undefined,
|
||||||
|
type: 'event' as const,
|
||||||
|
isPrivate,
|
||||||
|
added_at: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process articles (AddressPointer[])
|
||||||
|
if (applesauceBookmarks.articles) {
|
||||||
|
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
|
||||||
|
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
|
||||||
|
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
|
||||||
|
allItems.push({
|
||||||
|
id: coordinate,
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: article.pubkey,
|
||||||
|
kind: article.kind, // Usually 30023 for long-form articles
|
||||||
|
tags: [],
|
||||||
|
parsedContent: undefined,
|
||||||
|
type: 'event' as const,
|
||||||
|
isPrivate,
|
||||||
|
added_at: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process hashtags (string[])
|
||||||
|
if (applesauceBookmarks.hashtags) {
|
||||||
|
applesauceBookmarks.hashtags.forEach((hashtag: string) => {
|
||||||
|
allItems.push({
|
||||||
|
id: `hashtag-${hashtag}`,
|
||||||
|
content: `#${hashtag}`,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: activeAccount.pubkey,
|
||||||
|
kind: 1,
|
||||||
|
tags: [['t', hashtag]],
|
||||||
|
parsedContent: undefined,
|
||||||
|
type: 'event' as const,
|
||||||
|
isPrivate,
|
||||||
|
added_at: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process URLs (string[])
|
||||||
|
if (applesauceBookmarks.urls) {
|
||||||
|
applesauceBookmarks.urls.forEach((url: string) => {
|
||||||
|
allItems.push({
|
||||||
|
id: `url-${url}`,
|
||||||
|
content: url,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: activeAccount.pubkey,
|
||||||
|
kind: 1,
|
||||||
|
tags: [['r', url]],
|
||||||
|
parsedContent: undefined,
|
||||||
|
type: 'event' as const,
|
||||||
|
isPrivate,
|
||||||
|
added_at: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||||
|
return bookmarkArray
|
||||||
|
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||||
|
.map((bookmark: BookmarkData) => ({
|
||||||
|
id: bookmark.id!,
|
||||||
content: bookmark.content || '',
|
content: bookmark.content || '',
|
||||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
@@ -70,23 +160,8 @@ export const processApplesauceBookmarks = (
|
|||||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||||
}))
|
}))
|
||||||
}
|
|
||||||
|
|
||||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
|
||||||
return bookmarkArray.map((bookmark: BookmarkData) => ({
|
|
||||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
|
||||||
content: bookmark.content || '',
|
|
||||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
|
||||||
pubkey: activeAccount.pubkey,
|
|
||||||
kind: bookmark.kind || 30001,
|
|
||||||
tags: bookmark.tags || [],
|
|
||||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
|
||||||
type: 'event' as const,
|
|
||||||
isPrivate,
|
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types and guards around signer/decryption APIs
|
// Types and guards around signer/decryption APIs
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export async function collectBookmarksFromEvents(
|
|||||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||||
|
|
||||||
|
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
|
||||||
|
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
|
||||||
|
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
|
||||||
|
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
|
||||||
|
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
|
||||||
|
|
||||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||||
if (evt.kind === 39701) {
|
if (evt.kind === 39701) {
|
||||||
publicItemsAll.push({
|
publicItemsAll.push({
|
||||||
@@ -45,13 +51,27 @@ export async function collectBookmarksFromEvents(
|
|||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'web' as const,
|
type: 'web' as const,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
added_at: evt.created_at || Math.floor(Date.now() / 1000)
|
added_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||||
|
sourceKind: 39701,
|
||||||
|
setName: dTag,
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setImage
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const pub = Helpers.getPublicBookmarks(evt)
|
const pub = Helpers.getPublicBookmarks(evt)
|
||||||
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
|
publicItemsAll.push(
|
||||||
|
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
|
||||||
|
...i,
|
||||||
|
sourceKind: evt.kind,
|
||||||
|
setName: dTag,
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setImage
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
||||||
@@ -94,7 +114,16 @@ export async function collectBookmarksFromEvents(
|
|||||||
try {
|
try {
|
||||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||||
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
privateItemsAll.push(
|
||||||
|
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
||||||
|
...i,
|
||||||
|
sourceKind: evt.kind,
|
||||||
|
setName: dTag,
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setImage
|
||||||
|
}))
|
||||||
|
)
|
||||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||||
@@ -106,7 +135,16 @@ export async function collectBookmarksFromEvents(
|
|||||||
|
|
||||||
const priv = Helpers.getHiddenBookmarks(evt)
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
if (priv) {
|
if (priv) {
|
||||||
privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true))
|
privateItemsAll.push(
|
||||||
|
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
||||||
|
...i,
|
||||||
|
sourceKind: evt.kind,
|
||||||
|
setName: dTag,
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setImage
|
||||||
|
}))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore individual event failures
|
// ignore individual event failures
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
|
||||||
import {
|
import {
|
||||||
AccountWithExtension,
|
AccountWithExtension,
|
||||||
NostrEvent,
|
NostrEvent,
|
||||||
dedupeNip51Events,
|
dedupeNip51Events,
|
||||||
hydrateItems,
|
hydrateItems,
|
||||||
isAccountWithExtension,
|
isAccountWithExtension,
|
||||||
isHexId,
|
|
||||||
hasNip04Decrypt,
|
hasNip04Decrypt,
|
||||||
hasNip44Decrypt,
|
hasNip44Decrypt,
|
||||||
dedupeBookmarksById,
|
dedupeBookmarksById,
|
||||||
@@ -16,6 +14,7 @@ import { Bookmark } from '../types/bookmarks'
|
|||||||
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
import { rebroadcastEvents } from './rebroadcastService'
|
import { rebroadcastEvents } from './rebroadcastService'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -30,14 +29,13 @@ export const fetchBookmarks = async (
|
|||||||
if (!isAccountWithExtension(activeAccount)) {
|
if (!isAccountWithExtension(activeAccount)) {
|
||||||
throw new Error('Invalid account object provided')
|
throw new Error('Invalid account object provided')
|
||||||
}
|
}
|
||||||
// Get relay URLs from the pool
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
||||||
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
|
console.log('🔍 Fetching bookmark events')
|
||||||
const rawEvents = await lastValueFrom(
|
|
||||||
relayPool
|
const rawEvents = await queryEvents(
|
||||||
.req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
relayPool,
|
||||||
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
|
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
|
||||||
|
{}
|
||||||
)
|
)
|
||||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||||
|
|
||||||
@@ -58,13 +56,30 @@ export const fetchBookmarks = async (
|
|||||||
rawEvents.forEach((evt, i) => {
|
rawEvents.forEach((evt, i) => {
|
||||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||||
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
|
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
|
||||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, contentPreview=${contentPreview}`)
|
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
|
||||||
|
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
|
||||||
|
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
||||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
||||||
|
|
||||||
|
// Log which events made it through deduplication
|
||||||
|
bookmarkListEvents.forEach((evt, i) => {
|
||||||
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||||
|
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check specifically for Primal's "reads" list
|
||||||
|
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
||||||
|
if (primalReads) {
|
||||||
|
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
||||||
|
} else {
|
||||||
|
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
|
||||||
|
}
|
||||||
|
|
||||||
if (bookmarkListEvents.length === 0) {
|
if (bookmarkListEvents.length === 0) {
|
||||||
setBookmarks([])
|
// Keep existing bookmarks visible; do not clear list if nothing new found
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Aggregate across events
|
// Aggregate across events
|
||||||
@@ -98,18 +113,88 @@ export const fetchBookmarks = async (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||||
const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId)))
|
|
||||||
let idToEvent: Map<string, NostrEvent> = new Map()
|
// Separate hex IDs (regular events) from coordinates (addressable events)
|
||||||
|
const noteIds: string[] = []
|
||||||
|
const coordinates: string[] = []
|
||||||
|
|
||||||
|
allItems.forEach(i => {
|
||||||
|
// Check if it's a hex ID (64 character hex string)
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||||
|
noteIds.push(i.id)
|
||||||
|
} else if (i.id.includes(':')) {
|
||||||
|
// Coordinate format: kind:pubkey:identifier
|
||||||
|
coordinates.push(i.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||||
|
|
||||||
|
// Fetch regular events by ID
|
||||||
if (noteIds.length > 0) {
|
if (noteIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
const events = await lastValueFrom(
|
const events = await queryEvents(
|
||||||
relayPool.req(relayUrls, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
relayPool,
|
||||||
|
{ ids: Array.from(new Set(noteIds)) },
|
||||||
|
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||||
)
|
)
|
||||||
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
|
events.forEach((e: NostrEvent) => {
|
||||||
|
idToEvent.set(e.id, e)
|
||||||
|
// Also store by coordinate if it's an addressable event
|
||||||
|
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
|
||||||
|
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||||
|
idToEvent.set(coordinate, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch events for hydration:', error)
|
console.warn('Failed to fetch events by ID:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch addressable events by coordinates
|
||||||
|
if (coordinates.length > 0) {
|
||||||
|
try {
|
||||||
|
// Group by kind for more efficient querying
|
||||||
|
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
|
||||||
|
|
||||||
|
coordinates.forEach(coord => {
|
||||||
|
const parts = coord.split(':')
|
||||||
|
const kind = parseInt(parts[0])
|
||||||
|
const pubkey = parts[1]
|
||||||
|
const identifier = parts[2] || ''
|
||||||
|
|
||||||
|
if (!byKind.has(kind)) {
|
||||||
|
byKind.set(kind, [])
|
||||||
|
}
|
||||||
|
byKind.get(kind)!.push({ pubkey, identifier })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Query each kind group
|
||||||
|
for (const [kind, items] of byKind.entries()) {
|
||||||
|
const authors = Array.from(new Set(items.map(i => i.pubkey)))
|
||||||
|
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
{ kinds: [kind], authors, '#d': identifiers },
|
||||||
|
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||||
|
)
|
||||||
|
|
||||||
|
events.forEach((e: NostrEvent) => {
|
||||||
|
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||||
|
idToEvent.set(coordinate, e)
|
||||||
|
// Also store by event ID
|
||||||
|
idToEvent.set(e.id, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch addressable events:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
|
||||||
const allBookmarks = dedupeBookmarksById([
|
const allBookmarks = dedupeBookmarksById([
|
||||||
...hydrateItems(publicItemsAll, idToEvent),
|
...hydrateItems(publicItemsAll, idToEvent),
|
||||||
...hydrateItems(privateItemsAll, idToEvent)
|
...hydrateItems(privateItemsAll, idToEvent)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the contact list (follows) for a specific user
|
* Fetches the contact list (follows) for a specific user
|
||||||
@@ -9,40 +11,52 @@ import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
|||||||
*/
|
*/
|
||||||
export const fetchContacts = async (
|
export const fetchContacts = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkey: string
|
pubkey: string,
|
||||||
|
onPartial?: (contacts: Set<string>) => void
|
||||||
): Promise<Set<string>> => {
|
): Promise<Set<string>> => {
|
||||||
try {
|
try {
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
||||||
|
|
||||||
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
||||||
|
|
||||||
const events = await lastValueFrom(
|
const partialFollowed = new Set<string>()
|
||||||
relayPool
|
const events = await queryEvents(
|
||||||
.req(relayUrls, { kinds: [3], authors: [pubkey] })
|
relayPool,
|
||||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
{ kinds: [3], authors: [pubkey] },
|
||||||
|
{
|
||||||
|
relayUrls,
|
||||||
|
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
|
||||||
|
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
||||||
|
// Stream partials as we see any contact list
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'p' && tag[1]) {
|
||||||
|
partialFollowed.add(tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onPartial && partialFollowed.size > 0) {
|
||||||
|
onPartial(new Set(partialFollowed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
const followed = new Set<string>()
|
||||||
|
if (events.length > 0) {
|
||||||
|
// Get the most recent contact list
|
||||||
|
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
const contactList = sortedEvents[0]
|
||||||
|
// Extract pubkeys from 'p' tags
|
||||||
|
for (const tag of contactList.tags) {
|
||||||
|
if (tag[0] === 'p' && tag[1]) {
|
||||||
|
followed.add(tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onPartial) onPartial(new Set(followed))
|
||||||
|
}
|
||||||
|
// merged already via streams
|
||||||
|
|
||||||
console.log('📊 Contact events fetched:', events.length)
|
console.log('📊 Contact events fetched:', events.length)
|
||||||
|
|
||||||
if (events.length === 0) {
|
console.log('👥 Followed contacts:', followed.size)
|
||||||
return new Set()
|
return followed
|
||||||
}
|
|
||||||
|
|
||||||
// Get the most recent contact list
|
|
||||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
const contactList = sortedEvents[0]
|
|
||||||
|
|
||||||
// Extract pubkeys from 'p' tags
|
|
||||||
const followedPubkeys = new Set<string>()
|
|
||||||
for (const tag of contactList.tags) {
|
|
||||||
if (tag[0] === 'p' && tag[1]) {
|
|
||||||
followedPubkeys.add(tag[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('👥 Followed contacts:', followedPubkeys.size)
|
|
||||||
|
|
||||||
return followedPubkeys
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch contacts:', error)
|
console.error('Failed to fetch contacts:', error)
|
||||||
return new Set()
|
return new Set()
|
||||||
|
|||||||
70
src/services/dataFetch.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||||
|
import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Filter } from 'nostr-tools/filter'
|
||||||
|
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||||
|
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
|
||||||
|
|
||||||
|
export interface QueryOptions {
|
||||||
|
relayUrls?: string[]
|
||||||
|
localTimeoutMs?: number
|
||||||
|
remoteTimeoutMs?: number
|
||||||
|
onEvent?: (event: NostrEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified local-first query helper with optional streaming callback.
|
||||||
|
* Returns all collected events (deduped by id) after both streams complete or time out.
|
||||||
|
*/
|
||||||
|
export async function queryEvents(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
filter: Filter,
|
||||||
|
options: QueryOptions = {}
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
const {
|
||||||
|
relayUrls,
|
||||||
|
localTimeoutMs = LOCAL_TIMEOUT_MS,
|
||||||
|
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
|
||||||
|
onEvent
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const urls = relayUrls && relayUrls.length > 0
|
||||||
|
? relayUrls
|
||||||
|
: Array.from(relayPool.relays.values()).map(r => r.url)
|
||||||
|
|
||||||
|
const ordered = prioritizeLocalRelays(urls)
|
||||||
|
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
|
||||||
|
|
||||||
|
const local$: Observable<NostrEvent> = localRelays.length > 0
|
||||||
|
? relayPool
|
||||||
|
.req(localRelays, filter)
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(localTimeoutMs))
|
||||||
|
) as unknown as Observable<NostrEvent>
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
|
const remote$: Observable<NostrEvent> = remoteRelays.length > 0
|
||||||
|
? relayPool
|
||||||
|
.req(remoteRelays, filter)
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(remoteTimeoutMs))
|
||||||
|
) as unknown as Observable<NostrEvent>
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
|
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||||
|
|
||||||
|
// Deduplicate by id (callers can perform higher-level replaceable grouping if needed)
|
||||||
|
const byId = new Map<string, NostrEvent>()
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!byId.has(ev.id)) byId.set(ev.id, ev)
|
||||||
|
}
|
||||||
|
return Array.from(byId.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
48
src/services/deletionService.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { EventFactory } from 'applesauce-factory'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a kind:5 event deletion request (NIP-09)
|
||||||
|
* @param eventId The ID of the event to delete
|
||||||
|
* @param eventKind The kind of the event being deleted
|
||||||
|
* @param reason Optional reason for deletion
|
||||||
|
* @param account The user's account for signing
|
||||||
|
* @param relayPool The relay pool for publishing
|
||||||
|
* @returns The signed deletion request event
|
||||||
|
*/
|
||||||
|
export async function createDeletionRequest(
|
||||||
|
eventId: string,
|
||||||
|
eventKind: number,
|
||||||
|
reason: string | undefined,
|
||||||
|
account: IAccount,
|
||||||
|
relayPool: RelayPool
|
||||||
|
): Promise<NostrEvent> {
|
||||||
|
const factory = new EventFactory({ signer: account })
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['e', eventId],
|
||||||
|
['k', eventKind.toString()]
|
||||||
|
]
|
||||||
|
|
||||||
|
const draft = await factory.create(async () => ({
|
||||||
|
kind: 5, // Event Deletion Request
|
||||||
|
content: reason || '',
|
||||||
|
tags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
|
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
|
||||||
|
|
||||||
|
// Publish to relays
|
||||||
|
await relayPool.publish(RELAYS, signed)
|
||||||
|
|
||||||
|
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
|
||||||
|
|
||||||
|
return signed
|
||||||
|
}
|
||||||
|
|
||||||
73
src/services/exploreCache.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
|
||||||
|
export interface CachedBlogPostPreview {
|
||||||
|
event: NostrEvent
|
||||||
|
title: string
|
||||||
|
summary?: string
|
||||||
|
image?: string
|
||||||
|
published?: number
|
||||||
|
author: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheValue = {
|
||||||
|
posts: CachedBlogPostPreview[]
|
||||||
|
highlights: Highlight[]
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const exploreCache = new Map<string, CacheValue>() // key: pubkey
|
||||||
|
|
||||||
|
export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null {
|
||||||
|
const entry = exploreCache.get(pubkey)
|
||||||
|
if (!entry) return null
|
||||||
|
return entry.posts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedHighlights(pubkey: string): Highlight[] | null {
|
||||||
|
const entry = exploreCache.get(pubkey)
|
||||||
|
if (!entry) return null
|
||||||
|
return entry.highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
|
||||||
|
const current = exploreCache.get(pubkey)
|
||||||
|
exploreCache.set(pubkey, {
|
||||||
|
posts,
|
||||||
|
highlights: current?.highlights || [],
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedHighlights(pubkey: string, highlights: Highlight[]): void {
|
||||||
|
const current = exploreCache.get(pubkey)
|
||||||
|
exploreCache.set(pubkey, {
|
||||||
|
posts: current?.posts || [],
|
||||||
|
highlights,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] {
|
||||||
|
const current = exploreCache.get(pubkey)?.posts || []
|
||||||
|
const byId = new Map(current.map(p => [p.event.id, p]))
|
||||||
|
byId.set(post.event.id, post)
|
||||||
|
const merged = Array.from(byId.values()).sort((a, b) => {
|
||||||
|
const ta = a.published || a.event.created_at
|
||||||
|
const tb = b.published || b.event.created_at
|
||||||
|
return tb - ta
|
||||||
|
})
|
||||||
|
setCachedPosts(pubkey, merged)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertCachedHighlight(pubkey: string, highlight: Highlight): Highlight[] {
|
||||||
|
const current = exploreCache.get(pubkey)?.highlights || []
|
||||||
|
const byId = new Map(current.map(h => [h.id, h]))
|
||||||
|
byId.set(highlight.id, highlight)
|
||||||
|
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||||
|
setCachedHighlights(pubkey, merged)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -24,7 +24,8 @@ export interface BlogPostPreview {
|
|||||||
export const fetchBlogPostsFromAuthors = async (
|
export const fetchBlogPostsFromAuthors = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkeys: string[],
|
pubkeys: string[],
|
||||||
relayUrls: string[]
|
relayUrls: string[],
|
||||||
|
onPost?: (post: BlogPostPreview) => void
|
||||||
): Promise<BlogPostPreview[]> => {
|
): Promise<BlogPostPreview[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
@@ -33,43 +34,55 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
||||||
|
|
||||||
const events = await lastValueFrom(
|
|
||||||
relayPool
|
|
||||||
.req(relayUrls, {
|
|
||||||
kinds: [30023],
|
|
||||||
authors: pubkeys,
|
|
||||||
limit: 100 // Fetch up to 100 recent posts
|
|
||||||
})
|
|
||||||
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('📊 Blog post events fetched:', events.length)
|
|
||||||
|
|
||||||
// Deduplicate replaceable events by keeping the most recent version
|
// Deduplicate replaceable events by keeping the most recent version
|
||||||
// Group by author + d-tag identifier
|
// Group by author + d-tag identifier
|
||||||
const uniqueEvents = new Map<string, NostrEvent>()
|
const uniqueEvents = new Map<string, NostrEvent>()
|
||||||
|
|
||||||
for (const event of events) {
|
await queryEvents(
|
||||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
relayPool,
|
||||||
const key = `${event.pubkey}:${dTag}`
|
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
||||||
|
{
|
||||||
const existing = uniqueEvents.get(key)
|
relayUrls,
|
||||||
if (!existing || event.created_at > existing.created_at) {
|
onEvent: (event: NostrEvent) => {
|
||||||
uniqueEvents.set(key, event)
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${event.pubkey}:${dTag}`
|
||||||
|
const existing = uniqueEvents.get(key)
|
||||||
|
if (!existing || event.created_at > existing.created_at) {
|
||||||
|
uniqueEvents.set(key, event)
|
||||||
|
// Emit as we incorporate
|
||||||
|
if (onPost) {
|
||||||
|
const post: BlogPostPreview = {
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
}
|
||||||
|
onPost(post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
|
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
|
||||||
|
|
||||||
// Convert to blog post previews and sort by published date (most recent first)
|
// Convert to blog post previews and sort by published date (most recent first)
|
||||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||||
.map(event => ({
|
.map(event => {
|
||||||
event,
|
const post: BlogPostPreview = {
|
||||||
title: getArticleTitle(event) || 'Untitled',
|
event,
|
||||||
summary: getArticleSummary(event),
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
image: getArticleImage(event),
|
summary: getArticleSummary(event),
|
||||||
published: getArticlePublished(event),
|
image: getArticleImage(event),
|
||||||
author: event.pubkey
|
published: getArticlePublished(event),
|
||||||
}))
|
author: event.pubkey
|
||||||
|
}
|
||||||
|
if (onPost) onPost(post)
|
||||||
|
return post
|
||||||
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const timeA = a.published || a.event.created_at
|
const timeA = a.published || a.event.created_at
|
||||||
const timeB = b.published || b.event.created_at
|
const timeB = b.published || b.event.created_at
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import { Helpers, IEventStore } from 'applesauce-core'
|
|||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
import { areAllRelaysLocal } from '../utils/helpers'
|
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
||||||
import { markEventAsOfflineCreated } from './offlineSyncService'
|
import { publishEvent } from './writeService'
|
||||||
|
|
||||||
// Boris pubkey for zap splits
|
// Boris pubkey for zap splits
|
||||||
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||||
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getHighlightText,
|
getHighlightText,
|
||||||
@@ -118,59 +118,26 @@ export async function createHighlight(
|
|||||||
// Sign the event
|
// Sign the event
|
||||||
const signedEvent = await factory.sign(highlightEvent)
|
const signedEvent = await factory.sign(highlightEvent)
|
||||||
|
|
||||||
// Publish to all configured relays - let the relay pool handle connection state
|
// Use unified write service to store and publish
|
||||||
const targetRelays = RELAYS
|
await publishEvent(relayPool, eventStore, signedEvent)
|
||||||
|
|
||||||
// Store the event in the local EventStore FIRST for immediate UI display
|
// Check current connection status for UI feedback
|
||||||
eventStore.add(signedEvent)
|
|
||||||
console.log('💾 Stored highlight in EventStore:', signedEvent.id.slice(0, 8))
|
|
||||||
|
|
||||||
// Check current connection status - are we online or in flight mode?
|
|
||||||
const connectedRelays = Array.from(relayPool.relays.values())
|
const connectedRelays = Array.from(relayPool.relays.values())
|
||||||
.filter(relay => relay.connected)
|
.filter(relay => relay.connected)
|
||||||
.map(relay => relay.url)
|
.map(relay => relay.url)
|
||||||
|
|
||||||
const hasRemoteConnection = connectedRelays.some(url =>
|
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
||||||
!url.includes('localhost') && !url.includes('127.0.0.1')
|
const expectedSuccessRelays = hasRemoteConnection
|
||||||
)
|
? RELAYS
|
||||||
|
: RELAYS.filter(isLocalRelay)
|
||||||
// Determine which relays we expect to succeed
|
|
||||||
const expectedSuccessRelays = hasRemoteConnection
|
|
||||||
? RELAYS
|
|
||||||
: RELAYS.filter(r => r.includes('localhost') || r.includes('127.0.0.1'))
|
|
||||||
|
|
||||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||||
|
|
||||||
console.log('📍 Highlight relay status:', {
|
|
||||||
targetRelays: targetRelays.length,
|
|
||||||
expectedSuccessRelays,
|
|
||||||
isLocalOnly,
|
|
||||||
hasRemoteConnection,
|
|
||||||
eventId: signedEvent.id
|
|
||||||
})
|
|
||||||
|
|
||||||
// If we're in local-only mode, mark this event for later sync
|
|
||||||
if (isLocalOnly) {
|
|
||||||
markEventAsOfflineCreated(signedEvent.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to Highlight with relay tracking info and return IMMEDIATELY
|
// Convert to Highlight with relay tracking info and return IMMEDIATELY
|
||||||
const highlight = eventToHighlight(signedEvent)
|
const highlight = eventToHighlight(signedEvent)
|
||||||
highlight.publishedRelays = expectedSuccessRelays // Show only relays we expect to succeed
|
highlight.publishedRelays = expectedSuccessRelays
|
||||||
highlight.isLocalOnly = isLocalOnly
|
highlight.isLocalOnly = isLocalOnly
|
||||||
highlight.isOfflineCreated = isLocalOnly // Mark as created offline if local-only
|
highlight.isOfflineCreated = isLocalOnly
|
||||||
|
|
||||||
// Publish to relays in the background (non-blocking)
|
|
||||||
// This allows instant UI updates while publishing happens asynchronously
|
|
||||||
relayPool.publish(targetRelays, signedEvent)
|
|
||||||
.then(() => {
|
|
||||||
console.log('✅ Highlight published to', targetRelays.length, 'relay(s):', targetRelays)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.warn('⚠️ Failed to publish highlight to relays (event still saved locally):', error)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return the highlight immediately for instant UI updates
|
|
||||||
return highlight
|
return highlight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,204 +1,5 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
export * from './highlights/fetchForArticle'
|
||||||
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
|
export * from './highlights/fetchForUrl'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
export * from './highlights/fetchByAuthor'
|
||||||
import { Highlight } from '../types/highlights'
|
export * from './highlights/fetchFromAuthors'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
|
||||||
import { UserSettings } from './settingsService'
|
|
||||||
import { rebroadcastEvents } from './rebroadcastService'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches highlights for a specific article by its address coordinate and/or event ID
|
|
||||||
* @param relayPool - The relay pool to query
|
|
||||||
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
|
|
||||||
* @param eventId - Optional event ID to also query by 'e' tag
|
|
||||||
* @param onHighlight - Optional callback to receive highlights as they arrive
|
|
||||||
* @param settings - User settings for rebroadcast options
|
|
||||||
*/
|
|
||||||
export const fetchHighlightsForArticle = async (
|
|
||||||
relayPool: RelayPool,
|
|
||||||
articleCoordinate: string,
|
|
||||||
eventId?: string,
|
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
|
||||||
settings?: UserSettings
|
|
||||||
): Promise<Highlight[]> => {
|
|
||||||
try {
|
|
||||||
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
|
|
||||||
console.log('🔍 Event ID:', eventId || 'none')
|
|
||||||
console.log('🔍 From relays (including local):', RELAYS)
|
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
|
||||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
|
||||||
if (seenIds.has(event.id)) return null
|
|
||||||
seenIds.add(event.id)
|
|
||||||
return eventToHighlight(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query for highlights that reference this article via the 'a' tag
|
|
||||||
const aTagEvents = await lastValueFrom(
|
|
||||||
relayPool
|
|
||||||
.req(RELAYS, { kinds: [9802], '#a': [articleCoordinate] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) {
|
|
||||||
onHighlight(highlight)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(10000)),
|
|
||||||
toArray()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('📊 Highlights via a-tag:', aTagEvents.length)
|
|
||||||
|
|
||||||
// If we have an event ID, also query for highlights that reference via the 'e' tag
|
|
||||||
let eTagEvents: NostrEvent[] = []
|
|
||||||
if (eventId) {
|
|
||||||
eTagEvents = await lastValueFrom(
|
|
||||||
relayPool
|
|
||||||
.req(RELAYS, { kinds: [9802], '#e': [eventId] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) {
|
|
||||||
onHighlight(highlight)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(10000)),
|
|
||||||
toArray()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
console.log('📊 Highlights via e-tag:', eTagEvents.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine results from both queries
|
|
||||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
|
||||||
console.log('📊 Total raw highlight events fetched:', rawEvents.length)
|
|
||||||
|
|
||||||
// Rebroadcast highlight events to local/all relays based on settings
|
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
|
|
||||||
if (rawEvents.length > 0) {
|
|
||||||
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
|
|
||||||
} else {
|
|
||||||
console.log('❌ No highlights found. Article coordinate:', articleCoordinate)
|
|
||||||
console.log('❌ Event ID:', eventId || 'none')
|
|
||||||
console.log('💡 Try checking if there are any highlights on this article at https://highlighter.com')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicate events by ID
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
|
||||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
|
||||||
|
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
|
||||||
return sortHighlights(highlights)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch highlights for article:', error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches highlights for a specific URL
|
|
||||||
* @param relayPool - The relay pool to query
|
|
||||||
* @param url - The external URL to find highlights for
|
|
||||||
* @param settings - User settings for rebroadcast options
|
|
||||||
*/
|
|
||||||
export const fetchHighlightsForUrl = async (
|
|
||||||
relayPool: RelayPool,
|
|
||||||
url: string,
|
|
||||||
settings?: UserSettings
|
|
||||||
): Promise<Highlight[]> => {
|
|
||||||
try {
|
|
||||||
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
|
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
|
||||||
const rawEvents = await lastValueFrom(
|
|
||||||
relayPool
|
|
||||||
.req(RELAYS, { kinds: [9802], '#r': [url] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
seenIds.add(event.id)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(10000)),
|
|
||||||
toArray()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('📊 Highlights for URL:', rawEvents.length)
|
|
||||||
|
|
||||||
// Rebroadcast highlight events to local/all relays based on settings
|
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
|
||||||
return sortHighlights(highlights)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch highlights for URL:', error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches highlights created by a specific user
|
|
||||||
* @param relayPool - The relay pool to query
|
|
||||||
* @param pubkey - The user's public key
|
|
||||||
* @param onHighlight - Optional callback to receive highlights as they arrive
|
|
||||||
* @param settings - User settings for rebroadcast options
|
|
||||||
*/
|
|
||||||
export const fetchHighlights = async (
|
|
||||||
relayPool: RelayPool,
|
|
||||||
pubkey: string,
|
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
|
||||||
settings?: UserSettings
|
|
||||||
): Promise<Highlight[]> => {
|
|
||||||
try {
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
|
|
||||||
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
|
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
|
||||||
const rawEvents = await lastValueFrom(
|
|
||||||
relayPool
|
|
||||||
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
if (!seenIds.has(event.id)) {
|
|
||||||
seenIds.add(event.id)
|
|
||||||
const highlight = eventToHighlight(event)
|
|
||||||
if (onHighlight) {
|
|
||||||
onHighlight(highlight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(10000)),
|
|
||||||
toArray()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('📊 Raw highlight events fetched:', rawEvents.length)
|
|
||||||
|
|
||||||
// Rebroadcast highlight events to local/all relays based on settings
|
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
|
|
||||||
// Deduplicate and process events
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
|
||||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
|
||||||
|
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
|
||||||
return sortHighlights(highlights)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch highlights by author:', error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
63
src/services/highlights/fetchByAuthor.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||||
|
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Highlight } from '../../types/highlights'
|
||||||
|
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||||
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
|
import { UserSettings } from '../settingsService'
|
||||||
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
|
||||||
|
export const fetchHighlights = async (
|
||||||
|
relayPool: RelayPool,
|
||||||
|
pubkey: string,
|
||||||
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
|
settings?: UserSettings
|
||||||
|
): Promise<Highlight[]> => {
|
||||||
|
try {
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const ordered = prioritizeLocalRelays(relayUrls)
|
||||||
|
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
|
||||||
|
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const local$ = localRelays.length > 0
|
||||||
|
? relayPool
|
||||||
|
.req(localRelays, { kinds: [9802], authors: [pubkey] })
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
tap((event: NostrEvent) => {
|
||||||
|
if (!seenIds.has(event.id)) {
|
||||||
|
seenIds.add(event.id)
|
||||||
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(1200))
|
||||||
|
)
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
const remote$ = remoteRelays.length > 0
|
||||||
|
? relayPool
|
||||||
|
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
tap((event: NostrEvent) => {
|
||||||
|
if (!seenIds.has(event.id)) {
|
||||||
|
seenIds.add(event.id)
|
||||||
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(6000))
|
||||||
|
)
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||||
|
|
||||||
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
|
const highlights = uniqueEvents.map(eventToHighlight)
|
||||||
|
return sortHighlights(highlights)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
98
src/services/highlights/fetchForArticle.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||||
|
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Highlight } from '../../types/highlights'
|
||||||
|
import { RELAYS } from '../../config/relays'
|
||||||
|
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||||
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
|
import { UserSettings } from '../settingsService'
|
||||||
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
|
||||||
|
export const fetchHighlightsForArticle = async (
|
||||||
|
relayPool: RelayPool,
|
||||||
|
articleCoordinate: string,
|
||||||
|
eventId?: string,
|
||||||
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
|
settings?: UserSettings
|
||||||
|
): Promise<Highlight[]> => {
|
||||||
|
try {
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const processEvent = (event: NostrEvent): Highlight | null => {
|
||||||
|
if (seenIds.has(event.id)) return null
|
||||||
|
seenIds.add(event.id)
|
||||||
|
return eventToHighlight(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||||
|
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||||
|
|
||||||
|
const aLocal$ = localRelays.length > 0
|
||||||
|
? relayPool
|
||||||
|
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
tap((event: NostrEvent) => {
|
||||||
|
const highlight = processEvent(event)
|
||||||
|
if (highlight && onHighlight) onHighlight(highlight)
|
||||||
|
}),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(1200))
|
||||||
|
)
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
const aRemote$ = remoteRelays.length > 0
|
||||||
|
? relayPool
|
||||||
|
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
tap((event: NostrEvent) => {
|
||||||
|
const highlight = processEvent(event)
|
||||||
|
if (highlight && onHighlight) onHighlight(highlight)
|
||||||
|
}),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(6000))
|
||||||
|
)
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
|
||||||
|
|
||||||
|
let eTagEvents: NostrEvent[] = []
|
||||||
|
if (eventId) {
|
||||||
|
const eLocal$ = localRelays.length > 0
|
||||||
|
? relayPool
|
||||||
|
.req(localRelays, { kinds: [9802], '#e': [eventId] })
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
tap((event: NostrEvent) => {
|
||||||
|
const highlight = processEvent(event)
|
||||||
|
if (highlight && onHighlight) onHighlight(highlight)
|
||||||
|
}),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(1200))
|
||||||
|
)
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
const eRemote$ = remoteRelays.length > 0
|
||||||
|
? relayPool
|
||||||
|
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
tap((event: NostrEvent) => {
|
||||||
|
const highlight = processEvent(event)
|
||||||
|
if (highlight && onHighlight) onHighlight(highlight)
|
||||||
|
}),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(6000))
|
||||||
|
)
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||||
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
|
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||||
|
return sortHighlights(highlights)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||