678 Commits

Author SHA1 Message Date
codytseng
a519bbb048 💨 2025-12-25 18:24:08 +08:00
codytseng
078a8fd348 💨 2025-12-25 18:07:22 +08:00
codytseng
17d90a298a refactor: thread 2025-12-25 17:06:32 +08:00
codytseng
d964c7b7b3 fix: return 0 instead of null for missing user percentile data 2025-12-25 09:21:29 +08:00
codytseng
25b2831fcc feat: 💨 2025-12-24 23:31:18 +08:00
bitcoinuser
1553227e13 feat: improve signup copy in Portuguese translations (#703) 2025-12-24 22:58:26 +08:00
codytseng
f04981f5b9 fix: improve description display in RelaySimpleInfo component 2025-12-24 22:54:58 +08:00
codytseng
2662373704 fix: adjust layout for Signup component 2025-12-24 22:51:59 +08:00
codytseng
526b64aec0 feat: add border to image hash placeholder 2025-12-24 22:48:38 +08:00
codytseng
41a65338b5 fix: 🐛 2025-12-24 22:30:00 +08:00
codytseng
56f0aa9fd5 fix: 🐛 2025-12-24 13:22:38 +08:00
codytseng
89f79b999c refactor: reverse top-level replies order 2025-12-24 13:01:03 +08:00
bitcoinuser
7459a3d33a feat: update Portuguese translations for clarity and accuracy (#702) 2025-12-24 10:58:24 +08:00
codytseng
49eca495f5 refactor: 🎨 2025-12-24 10:55:05 +08:00
codytseng
96abe5f24f feat: add compatibility for legacy comments 2025-12-23 23:30:57 +08:00
codytseng
0ee93718da feat: add relay recommendations based on user language 2025-12-23 22:28:07 +08:00
codytseng
a880a92748 feat: simplify account creation flow 2025-12-23 21:52:32 +08:00
codytseng
cd7c52eda0 feat: batch fetch user percentiles 2025-12-22 22:34:29 +08:00
codytseng
ef6d44d112 feat: add Traditional Chinese language support 2025-12-22 18:13:31 +08:00
bitcoinuser
2925c0c5f9 feat: update Portuguese translations for clarity (#697) 2025-12-22 14:52:55 +08:00
Max Blake
5705d8c9b3 feat: update pl.ts (#698)
Co-authored-by: Cody Tseng <codytseng98@gmail.com>
2025-12-22 14:52:16 +08:00
codytseng
944246b582 feat: 💨 2025-12-21 23:50:49 +08:00
codytseng
163f3212d8 chore: 🎨 2025-12-21 21:11:51 +08:00
codytseng
1193c81c78 fix: 🐛 2025-12-20 19:35:51 +08:00
codytseng
ddb88bf074 refactor: restructure the reply list 2025-12-20 19:22:27 +08:00
codytseng
079a2f90ef feat: add support for publishing highlights 2025-12-18 21:53:07 +08:00
bitcoinuser
c4881e3435 feat: update Portuguese translations (#695) 2025-12-17 09:08:18 +08:00
codytseng
bc7fb4c79b fix: 🐛 2025-12-16 22:35:12 +08:00
codytseng
7677beef82 fix: correctly handle reactions for replaceable events 2025-12-16 21:36:50 +08:00
codytseng
6cf78992a6 refactor: feed switcher 2025-12-16 21:32:24 +08:00
codytseng
ce2976e3f9 fix: 🐛 2025-12-16 09:33:46 +08:00
codytseng
dbcb48d599 feat: add special follow feed 2025-12-15 22:46:04 +08:00
codytseng
4eb68d36d4 feat: 💨 2025-12-15 21:57:54 +08:00
The Daniel ⚡️
36f6d810ac fix: correct timestamp pluralization for singular forms (#694)
Co-authored-by: The Daniel <dmnyc@users.noreply.github.com>
2025-12-15 09:09:41 +08:00
codytseng
9f428aed8c doc: update interesting forks list 2025-12-14 16:16:15 +08:00
Max Blake
1f779775a0 feat: update pl.ts (#692) 2025-12-14 16:14:30 +08:00
codytseng
62c7c7eb5c chore: update translations 2025-12-12 22:46:41 +08:00
codytseng
2422d24d68 fix: 🐛 2025-12-12 14:13:29 +08:00
codytseng
ddcaa71cae fix: 🐛 2025-12-12 12:17:23 +08:00
codytseng
4d7c55862c fix: 🎨 2025-12-12 10:32:28 +08:00
codytseng
51fc7d4c05 feat: add support for thumbhash 2025-12-12 10:23:13 +08:00
codytseng
f6f974adc6 feat: add NSFW display policy setting 2025-12-11 23:37:05 +08:00
codytseng
c2b0e6f666 fix: prevent duplicate post submissions 2025-12-11 23:04:25 +08:00
codytseng
a6d6a19199 feat: nak req 2025-12-10 23:10:47 +08:00
codytseng
ac196cd662 feat: hide relay reviews from spammer 2025-12-09 22:35:06 +08:00
codytseng
d90348dd97 feat: allow fetching profiles from cache when zapping 2025-12-06 23:56:33 +08:00
codytseng
4e77975179 chore: add logging for zap endpoint fetch failures 2025-12-06 11:38:11 +08:00
codytseng
21ab8bc46c refactor: split YouTube and X post into separate components 2025-12-06 11:31:46 +08:00
codytseng
47958c2b23 feat: update trust score thresholds 2025-12-04 23:26:27 +08:00
codytseng
6bcab6d563 refactor: polish UI details 2025-12-04 23:24:16 +08:00
codytseng
881dedb6b6 feat: limit displayed length of reaction content 2025-12-04 09:18:43 +08:00
codytseng
33fa1ec441 feat: quick reaction 2025-12-03 23:28:31 +08:00
codytseng
77d56265ad feat: add support for .3gp 2025-12-02 13:21:20 +08:00
codytseng
7cc159f583 feat: improve interaction after clicking show new notes button 2025-12-01 20:58:00 +08:00
codytseng
1ec68f5696 feat: 💨 2025-12-01 10:19:15 +08:00
codytseng
0c1dab89da feat: add loading animation for pin and unpin actions 2025-12-01 00:35:03 +08:00
Max Blake
65888c4296 feat: update pl.ts (#683) 2025-12-01 00:27:40 +08:00
codytseng
a6c41d8d3f feat: 💨 2025-12-01 00:16:52 +08:00
codytseng
7ec4835c61 feat: pinned users event 2025-12-01 00:05:09 +08:00
codytseng
ad016aba35 feat: 🎨 2025-11-30 11:50:43 +08:00
codytseng
b345e2039f feat: 💨 2025-11-29 15:24:46 +08:00
codytseng
3149a3bf3c fix: remove unnecessary container clearing and adjust overlay background opacity 2025-11-29 15:22:04 +08:00
codytseng
f48f7d9edd feat: 💨 2025-11-29 12:17:52 +08:00
codytseng
a9f115d202 Revert "docs: add description for responsive menu component in AGENTS.md"
This reverts commit 72a2ded8ee.
2025-11-29 12:06:21 +08:00
codytseng
3eb018f39f Revert "refactor: responsive menu"
This reverts commit 1dc18645b2.
2025-11-29 12:06:13 +08:00
codytseng
019dbc073c fix: 🐛 2025-11-29 12:03:26 +08:00
codytseng
b29c2c1571 fix: add inline-block class to ExternalLink container for better layout 2025-11-29 00:52:06 +08:00
codytseng
ce7afeb250 feat: 24h pulse 2025-11-29 00:34:53 +08:00
codytseng
b21855c294 feat: support for follow packs 2025-11-27 00:02:13 +08:00
codytseng
cdab9aa19e fix: 🎨 2025-11-27 00:00:51 +08:00
codytseng
72a2ded8ee docs: add description for responsive menu component in AGENTS.md 2025-11-26 22:20:50 +08:00
codytseng
1dc18645b2 refactor: responsive menu 2025-11-26 22:18:17 +08:00
codytseng
c84c479871 feat: add badge for suspicious and spam users 2025-11-25 23:11:31 +08:00
codytseng
2b4f673df1 feat: add pubkey to q tag 2025-11-25 10:16:54 +08:00
Sean
9d4df54b3e fix: handle invalid invoice errors with try-catch (#680) 2025-11-25 09:47:01 +08:00
codytseng
a8fa3e1ecd fix: filter out onion relay reviews on browsers without onion support 2025-11-22 00:22:08 +08:00
codytseng
a6a8ac04ac fix: 🐛 2025-11-21 14:15:35 +08:00
codytseng
8edec0f7f6 feat: replace trending notes service 2025-11-21 10:35:45 +08:00
codytseng
18ae2a5fd4 feat: 💨 2025-11-20 21:31:48 +08:00
codytseng
ce72098175 fix: ensure content is empty when reposting replaceable events 2025-11-20 21:16:42 +08:00
codytseng
a40d7b0676 feat: support for generic repost 2025-11-20 13:34:05 +08:00
codytseng
14b3fbd496 refactor: 💨 2025-11-19 23:20:59 +08:00
codytseng
926c3f62a0 fix: correct mistyped kind 2025-11-19 21:41:20 +08:00
codytseng
c845a4fc4f fix: 🐛 2025-11-19 09:58:03 +08:00
bitcoinuser
25d8ad1532 feat: update pt-BR.ts (#676) 2025-11-18 21:21:03 +08:00
codytseng
0169eb3ba7 fix: 🐛 2025-11-18 21:14:49 +08:00
codytseng
7065015462 feat: add highlights to quotes 2025-11-17 22:37:15 +08:00
codytseng
b4366325cd Revert "feat: support displaying highlights in replies"
This reverts commit d2c5c923a3.
2025-11-17 22:37:15 +08:00
bitcoinuser
61d09a9482 feat: update Nostr comments and discussions translations (#675) 2025-11-17 21:56:14 +08:00
codytseng
d2c5c923a3 feat: support displaying highlights in replies 2025-11-17 21:55:24 +08:00
codytseng
d3f0704eae feat: disable automatic insertion of new notes in NoteList by default 2025-11-17 21:35:47 +08:00
codytseng
65d44394a6 feat: add support for addressable videos 2025-11-17 21:28:37 +08:00
codytseng
cde0b49e2e chore: improve translations 2025-11-16 14:35:34 +08:00
codytseng
dada8c83b5 fix: 🐛 2025-11-16 13:31:31 +08:00
bitcoinuser
c177c6e369 feat: update translation for Nostr discussions in Portuguese (#666) 2025-11-16 13:24:48 +08:00
codytseng
9d4eec350c feat: 💨 2025-11-15 21:06:36 +08:00
codytseng
0bb62dd3fb feat: add support for commenting and reacting on external content 2025-11-15 16:26:19 +08:00
codytseng
5ba5c26fcd feat: add option to disable filtering for onion relays 2025-11-15 13:58:20 +08:00
codytseng
606f9af1ba fix: show profile banner and avatar when media auto-load is disabled 2025-11-15 11:46:57 +08:00
codytseng
bcafbcc48c refactor: 💨 2025-11-14 22:51:37 +08:00
blackcoffeexbt
f77c228a02 fix: handle callback URLs with ? 2025-11-14 22:04:15 +08:00
Alex Gleason
19eaf1a4e3 feat: add lightbox to profile avatar and banner (#661) 2025-11-14 22:02:21 +08:00
Alex Gleason
82c13006ff feat: support NIP-30 custom emojis in bio and display name (#660) 2025-11-14 22:01:29 +08:00
Alex Gleason
f8cca5522f feat: configurable favicon service URL (#659) 2025-11-14 16:28:10 +08:00
The Daniel ⚡️
e544c0a801 fix: preserve linebreaks between URLs and Nostr references in editor (#657)
Co-authored-by: The Daniel <dmnyc@users.noreply.github.com>
2025-11-13 10:01:06 +08:00
codytseng
8ed28a79b1 fix: 🐛 2025-11-11 21:53:57 +08:00
codytseng
485ca82e30 fix: 🐛 2025-11-09 22:37:07 +08:00
codytseng
9b9ecf76d6 feat: 💨 2025-11-09 18:53:04 +08:00
codytseng
7139211fa1 fix: prioritize mention and embedded event parsing 2025-11-09 16:32:59 +08:00
Max Blake
6202acf8fa feat: update pl.ts (#650) 2025-11-09 16:15:43 +08:00
codytseng
fe9b55dcfd feat: optimize cache update strategy 2025-11-09 16:09:11 +08:00
codytseng
c11fc56209 refactor: 💨 2025-11-09 13:28:58 +08:00
codytseng
850d92de28 feat: NIP-43 2025-11-09 00:26:16 +08:00
codytseng
6614a615c4 docs: add common components section to AGENTS.md 2025-11-08 15:16:40 +08:00
codytseng
f28e920e15 perf: reduce re-renders in NoteList 2025-11-07 23:22:28 +08:00
codytseng
1e2385da3b feat: emoji packs 2025-11-07 22:36:07 +08:00
codytseng
0e550d2511 style: format 2025-11-07 10:54:04 +08:00
codytseng
9615a7e778 fix: 🐛 2025-11-07 10:53:42 +08:00
The Daniel ⚡️
71c9fc8485 fix: move selfZapWarning translations to end of files and add missing commas (#647)
Co-authored-by: The Daniel <dmnyc@users.noreply.github.com>
2025-11-07 10:13:40 +08:00
The Daniel ⚡️
bc3396fed2 feat: enable self-zapping with friendly warning message (#645)
Co-authored-by: The Daniel <dmnyc@users.noreply.github.com>
2025-11-07 09:47:11 +08:00
codytseng
ce87cd88a0 fix: 🎨 2025-11-07 09:26:12 +08:00
codytseng
9dad07344d fix: 🐛 2025-11-06 09:24:24 +08:00
The Daniel ⚡️
b0b9a391a4 feat: display invoice memo in Lightning invoice cards (#643)
Co-authored-by: The Daniel <dmnyc@users.noreply.github.com>
2025-11-05 10:15:21 +08:00
codytseng
0eb0be4780 docs: add AI agent reference documentation 2025-11-04 23:20:10 +08:00
codytseng
158d875123 feat: scroll to top when switching explore tabs 2025-11-04 21:53:15 +08:00
codytseng
579385ce3d refactor: page manager 2025-11-03 17:41:01 +08:00
codytseng
1b7ec56c89 fix: fetch profile from current relays if user has no relay list 2025-11-03 09:52:11 +08:00
codytseng
e556b83f5c fix: fetch profile event from user's write relays if not found in big relays 2025-11-03 09:22:35 +08:00
codytseng
e4f250907f feat: implement drawer for profile options on small screens 2025-11-02 23:02:37 +08:00
Lez
962359dcf5 feat: add Hungarian localization (#640)
Co-authored-by: Lez <lez@github.com>
2025-11-02 22:37:25 +08:00
codytseng
dab47e9a69 fix: handle JSON parse error for repost event content 2025-11-02 22:02:01 +08:00
codytseng
2ae223b552 feat: optimize profile caching mechanism 2025-11-02 16:59:05 +08:00
codytseng
6b22380d26 feat: optimize relay selection for replaceable event queries 2025-11-02 15:57:36 +08:00
codytseng
a3f02df539 feat: prevent vertical overscroll behavior on body and html elements 2025-11-02 12:42:29 +08:00
codytseng
222527ec7c feat: aggregate multiple reposts to avoid duplicate posts in feed 2025-11-01 19:36:01 +08:00
codytseng
934c56a20d feat: 💨 2025-11-01 17:12:13 +08:00
codytseng
38bc425d50 feat: remove default favorite relays 2025-11-01 15:56:11 +08:00
codytseng
24348d4f01 feat: optimize relay selection for reply fetching 2025-10-31 23:54:16 +08:00
Alex Gleason
f5ea1ce69e feat: allow clicking the logo to go home (#636) 2025-10-31 14:27:25 +08:00
codytseng
98bc077bb5 fix: remove redundant apple-touch-icon link from index.html 2025-10-30 22:45:49 +08:00
codytseng
65bb7abf57 fix: show error messages on report failure 2025-10-30 22:16:01 +08:00
Cody Tseng
b842d1eabf fix: show ScrollToTopButton in two-column layout (#635) 2025-10-30 17:32:06 +08:00
codytseng
63c9713ea8 refactor: replies 2025-10-29 23:12:54 +08:00
codytseng
25233344e7 fix: 🎨 2025-10-28 23:15:49 +08:00
codytseng
ca22d405bf feat: filter out reviews about localhost relays 2025-10-28 22:21:28 +08:00
codytseng
cb19d8256b fix: handle invalid URLs in truncateUrl function 2025-10-27 22:21:47 +08:00
codytseng
4e529bc5e5 fix: 🐛 2025-10-27 16:26:52 +08:00
bitcoinuser
ef05b32d32 feat: update translation for 'Add an Account' in Portuguese (#630) 2025-10-27 09:53:23 +08:00
codytseng
3bf4ecbe0f docs: update readme 2025-10-26 22:40:27 +08:00
codytseng
39d8282d7b feat: 💨 2025-10-26 22:24:44 +08:00
codytseng
248b4ec93c fix: 🐛 2025-10-26 16:21:40 +08:00
codytseng
d31cda7448 feat: enable single column layout by default 2025-10-26 16:13:26 +08:00
codytseng
ad6b8890c5 feat: add quick account switch interaction 2025-10-26 16:11:21 +08:00
codytseng
f33c5260df fix: hide scrollbar in dropdown menu content 2025-10-26 14:20:33 +08:00
codytseng
215502a0f0 fix: reset scroll position record on account switch 2025-10-25 23:06:42 +08:00
codytseng
def13e4f34 fix: 🐛 2025-10-25 22:45:45 +08:00
codytseng
e089aa9663 fix: make mentions list scrollable 2025-10-25 17:51:14 +08:00
codytseng
4fedc8bece fix: update regex and improve bech32 handling in parseEditorJsonToText 2025-10-25 16:26:08 +08:00
codytseng
0d4e8c5c21 fix: 🐛 2025-10-25 16:12:31 +08:00
codytseng
92041b73f1 fix: 🐛 2025-10-25 14:54:37 +08:00
codytseng
402bc91566 fix: prevent image context menu and drag 2025-10-24 22:59:53 +08:00
codytseng
1274942f64 feat: improve 🌸 2025-10-24 22:41:16 +08:00
codytseng
36c9796ea1 feat: relay reviews tab 2025-10-23 23:38:44 +08:00
codytseng
d93b3e865b feat: 💨 2025-10-23 22:17:20 +08:00
codytseng
0df5fb7e3e feat: adjust relay reviews width 2025-10-23 22:00:00 +08:00
codytseng
dceebd1650 feat: refresh profile page after posting 2025-10-22 22:56:13 +08:00
codytseng
c5009f659e style: 🎨 2025-10-22 22:22:38 +08:00
codytseng
cd4236b310 fix: improve source tag priority handling in HighlightSource 2025-10-22 22:20:45 +08:00
codytseng
1dd3b7d301 feat: layout switcher 2025-10-22 22:11:33 +08:00
codytseng
982f0c7d21 fix: 🐛 2025-10-21 18:19:32 +08:00
codytseng
897a343936 feat: 💨 2025-10-21 11:17:38 +08:00
codytseng
5fe2dc619a feat: show scrollbar 2025-10-21 10:06:39 +08:00
codytseng
1a0eacb077 chore: update default nostrconnect relays 2025-10-20 21:31:58 +08:00
codytseng
09c05cc62a fix: update sidebar button active state logic 2025-10-20 10:00:18 +08:00
codytseng
6090c01965 style: hide scrollbar 2025-10-20 09:57:49 +08:00
codytseng
fd342e1cad feat: 💨 2025-10-19 23:24:33 +08:00
codytseng
025b61fb6e style: update emoji picker styles 2025-10-19 21:53:10 +08:00
codytseng
6f22fdb6ca fix: 🐛 2025-10-19 21:43:17 +08:00
codytseng
325d3ca5c3 feat: improve single-column layout 2025-10-19 21:42:01 +08:00
codytseng
80a2f38272 feat: 💨 2025-10-19 18:50:35 +08:00
codytseng
936b15e5c2 feat: improve single-column layout 2025-10-19 18:41:22 +08:00
codytseng
1674671d7f refactor: update layout labels 2025-10-19 16:02:10 +08:00
codytseng
5c2a016d4b refactor: move bookmarks entry location 2025-10-19 15:46:45 +08:00
codytseng
a847c4dc56 style: reduce clickable background opacity to 0.3 2025-10-19 15:23:20 +08:00
codytseng
666e417a13 fix: update ring color when primary color changes 2025-10-19 15:23:20 +08:00
codytseng
cb46e7f3d5 refactor: move notification list style setting to appearance page 2025-10-19 15:23:20 +08:00
codytseng
dbee10361b feat: add single column layout toggle option 2025-10-19 15:23:20 +08:00
bitcoinuser
56729e09c3 feat: update Portuguese translations for media loading prompts (#601) 2025-10-18 23:52:13 +08:00
codytseng
bd3dc894c4 feat: format highlight source url 2025-10-18 23:39:13 +08:00
codytseng
28dad7373f feat: support primary color customization 2025-10-18 23:18:44 +08:00
codytseng
b17846f264 feat: pure black 2025-10-18 17:54:28 +08:00
codytseng
057de9595b refactor: sidebar 2025-10-17 23:34:56 +08:00
codytseng
21663711f8 feat: simplify external link display 2025-10-17 22:37:09 +08:00
codytseng
f23493742b feat: following badge 2025-10-16 22:42:24 +08:00
codytseng
f7051ed46b feat: add support for ultrawide screens 2025-10-16 10:10:47 +08:00
codytseng
978707e434 fix: update regex patterns to be case-insensitive 2025-10-16 09:15:05 +08:00
codytseng
9fbbb4896c feat: add section for interesting forks of Jumble 2025-10-15 23:39:15 +08:00
codytseng
72d43cceec feat: add note search to profile page 2025-10-15 22:35:24 +08:00
codytseng
f5ce41dc54 feat: 💨 2025-10-14 23:17:35 +08:00
codytseng
b038728013 feat: pin + reaction as the first suggestion 2025-10-13 22:22:35 +08:00
codytseng
ff9066ed1c feat: 💨 2025-10-12 22:09:21 +08:00
codytseng
3ae5a74395 feat: update pinned button text color 2025-10-12 21:51:51 +08:00
codytseng
de2b5a05bd fix: 🐛 2025-10-12 21:45:47 +08:00
codytseng
d131026af9 feat: add pinned post functionality 2025-10-12 21:39:16 +08:00
codytseng
9c554da2da feat: 💨 2025-10-12 15:16:45 +08:00
codytseng
9bba055e92 feat: auto switch to editor on preview click 2025-10-12 15:15:13 +08:00
codytseng
2d1e4507f4 feat: adjust save relay button style 2025-10-12 15:08:30 +08:00
codytseng
1f911c3a75 feat: improve media playback experience 2025-10-11 23:19:30 +08:00
codytseng
fb5434da91 chore: 💨 2025-10-11 16:10:36 +08:00
codytseng
9c71db405a fix: prevent duplicate clicks before zap or like action completes 2025-10-10 21:12:31 +08:00
codytseng
95061ec443 fix: 🐛 2025-10-10 09:24:19 +08:00
codytseng
c9723de863 feat: render ExternalLink when media type is not determined 2025-10-10 09:16:02 +08:00
codytseng
0444c887f4 fix: 🐛 2025-10-09 23:18:20 +08:00
codytseng
6eb3bccd38 feat: add error handling for audio, video, and YouTube players 2025-10-09 22:22:16 +08:00
codytseng
3395bad78b fix: 🐛 2025-10-06 12:56:11 +08:00
Alex Gleason
4adae16b80 refactor: get GIT_COMMIT and APP_VERSION from import.meta.env (#592) 2025-10-06 12:42:28 +08:00
codytseng
687eca821d feat: require success on one-third of relays to consider publish successful 2025-09-30 16:59:14 +08:00
codytseng
bdeee4ec31 feat: adjust target relays for reviews 2025-09-30 15:36:36 +08:00
codytseng
98c6555ba5 fix: add missing translations 2025-09-30 14:55:29 +08:00
codytseng
8ae3b8d6c8 fix: 💨 2025-09-29 23:41:43 +08:00
codytseng
6357fd5b44 feat: rizful 2025-09-29 23:36:49 +08:00
codytseng
520649e862 feat: show zap request failure reason 2025-09-28 23:00:29 +08:00
codytseng
80a9b8a34d fix: remove unnecessary delay in file upload process 2025-09-28 22:12:19 +08:00
bitcoinuser
7174556e73 feat: update pt-BR.ts (#588) 2025-09-27 10:42:01 +08:00
codytseng
3645f1aac8 feat: 💨 2025-09-24 14:47:04 +08:00
codytseng
9337b8adc7 feat: add scroll indicators to dropdown menu content 2025-09-24 14:43:54 +08:00
codytseng
5211556c7b fix: add missing translations 2025-09-24 11:17:50 +08:00
codytseng
83c95782bd feat: enable scrolling for overflowing drawer content 2025-09-24 11:15:35 +08:00
codytseng
b73b3512f3 fix: ignore close reasons from nostr-tools 2025-09-24 10:08:49 +08:00
codytseng
9267458bca feat: show relay close reasons in certain feeds 2025-09-23 22:42:25 +08:00
codytseng
c110b303d7 feat: adjust custom emoji size in content preview 2025-09-23 22:21:54 +08:00
codytseng
ac007acbd7 fix: reset selected index on input change 2025-09-23 22:04:00 +08:00
codytseng
5ef08b933e feat: add loading toast for republish actions 2025-09-23 21:51:10 +08:00
codytseng
1ff965969d fix: 🐛 2025-09-22 22:45:38 +08:00
bitcoinuser
766f890b93 feat: update pt-BR.ts (#587) 2025-09-22 21:45:14 +08:00
Cody Tseng
75a760fadb feat: added QR code scanner for bunker login (#586)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
2025-09-21 23:05:04 +08:00
hoppe
c4643b2d8b refactor: replace temporary nip46 file with official nostr-tools (#583) 2025-09-21 21:44:50 +08:00
codytseng
ec11d53fac feat: add relay selector for posting 2025-09-21 21:43:09 +08:00
codytseng
2439150c6e feat: relay reviews 2025-09-20 22:00:28 +08:00
codytseng
fcb31d8052 feat: update recommend blossom servers 2025-09-17 10:17:39 +08:00
hoppe
15608f7cf2 feat: update ko.ts (#579)
Co-authored-by: seokmin.seo <seokmin.seo@coinone.com>
2025-09-16 10:04:08 +08:00
codytseng
913d74a0fb fix: add missing translations 2025-09-15 21:28:23 +08:00
codytseng
de9ed04ca8 feat: add refresh button for non-touch devices 2025-09-15 21:20:46 +08:00
codytseng
2926be0d87 feat: localize preset zap amounts base on language 2025-09-15 21:14:22 +08:00
codytseng
32b9f5b134 fix: update recommend blossom servers 2025-09-15 21:13:47 +08:00
codytseng
287564ee09 feat: increase limit 2025-09-14 15:16:10 +08:00
Max Blake
ea99c445f6 feat: update pl.ts (#575) 2025-09-14 11:15:48 +08:00
codytseng
206cbf7e19 feat: 💨 2025-09-13 23:36:49 +08:00
codytseng
f785d0d8a2 feat: add auto-load media content setting option 2025-09-13 23:05:21 +08:00
codytseng
6d7ecfe2fd feat: set publish timeout to 10 seconds 2025-09-12 21:52:12 +08:00
codytseng
7606c62b63 feat: improve search user experience 2025-09-11 22:07:56 +08:00
codytseng
8ba9d872e2 feat: shorten search placeholder text 2025-09-11 10:27:33 +08:00
codytseng
8655fd1840 fix: 🐛 2025-09-11 10:09:32 +08:00
codytseng
1a58e54ed0 feat: improve search input user experience 2025-09-11 09:43:53 +08:00
codytseng
6571468150 feat: 💨 2025-09-11 09:19:41 +08:00
codytseng
63869ef3b7 feat: 💨 2025-09-10 22:15:31 +08:00
codytseng
5f165308c1 feat: add truncation to relay name 2025-09-10 16:49:19 +08:00
codytseng
60eaa82955 fix: 🐛 2025-09-09 10:07:53 +08:00
codytseng
4cc16d5e58 feat: pow 2025-09-08 23:20:22 +08:00
codytseng
95f4b207d5 fix: some bugs 2025-09-08 09:21:46 +08:00
codytseng
a6baca3009 feat: add homepage url to relay details 2025-09-07 22:57:34 +08:00
codytseng
7afd493619 feat: submit relay button 2025-09-07 22:47:10 +08:00
codytseng
ace4e94071 feat: explore 2025-09-07 22:23:01 +08:00
codytseng
f2bb65acf0 feat: add Hindi language support 2025-09-07 13:25:50 +08:00
codytseng
647f9062f8 feat: 💨 2025-09-06 16:44:50 +08:00
codytseng
fc138609a1 feat: add setting for notification list style 2025-09-06 13:49:13 +08:00
codytseng
71994be407 feat: support sending 1984 reports 2025-09-06 00:15:54 +08:00
codytseng
7562ae2c77 revert 2025-09-05 23:07:52 +08:00
codytseng
ab51f768f9 feat: 💨 2025-09-05 22:53:55 +08:00
codytseng
be3c0023fe fix: some bugs 2025-09-05 22:28:13 +08:00
codytseng
2855754648 feat: optimize notifications 2025-09-05 22:08:17 +08:00
codytseng
47e7a18f2e feat: publish likes to seen on relays 2025-09-05 10:28:22 +08:00
codytseng
4bfc9a1fff fix: update URL regex patterns to improve URL matching 2025-09-05 10:21:42 +08:00
24f0b32611 feat: immediately insert user-created event into feed (#534) 2025-09-04 10:42:13 +08:00
codytseng
336c6e993a fix: resolve new notes button not displaying 2025-09-03 09:44:25 +08:00
codytseng
584777328c chore: i18n 2025-09-02 23:22:14 +08:00
codytseng
61bf2a0450 fix: adjust padding in SearchInput component 2025-09-02 23:15:35 +08:00
codytseng
bddc7bde60 fix: 🐛 2025-09-02 22:57:44 +08:00
Daniel Vergara
b8bdd4ac5b chore: add the nostr-rs-relay to the docker compose (#522) 2025-09-02 22:38:43 +08:00
Cody Tseng
45e20329a3 feat: add a label to the kind filter button (#527)
Co-authored-by: Daniel  Vergara <daniel.omar.vergara@gmail.com>
2025-09-02 22:38:17 +08:00
Cody Tseng
3c657dfa8c feat: hide content mentioning muted users (#524)
Co-authored-by: mleku <me@mleku.dev>
2025-09-02 22:18:34 +08:00
bitcoinuser
d3578184fb feat: update pt-BR.ts (#521) 2025-09-02 09:54:35 +08:00
codytseng
d189d51e26 fix: 🐛 2025-09-01 22:31:32 +08:00
codytseng
a1285fe44d feat: 💨 2025-09-01 13:40:58 +08:00
codytseng
1ee3718cab fix: hid mute actions for own notes 2025-08-31 22:49:55 +08:00
codytseng
0153465e29 refactor: search 2025-08-31 22:43:47 +08:00
bitcoinuser
88567c2c13 feat: update pt-BR.ts (#514) 2025-08-31 15:40:39 +08:00
codytseng
4f8d6a0a11 fix: 🐛 2025-08-31 12:05:11 +08:00
codytseng
b995e2150b fix: 🐛 2025-08-31 01:06:28 +08:00
codytseng
d1ac257ff9 feat: 💨 2025-08-31 01:02:40 +08:00
codytseng
b3b7176bcd feat: 💨 2025-08-31 00:08:30 +08:00
codytseng
9589095dc5 feat: support bookmarking replaceable events 2025-08-30 23:34:17 +08:00
bitcoinuser
2ee9037322 feat: update pt-BR.ts (#510) 2025-08-30 23:09:49 +08:00
codytseng
a125e37f8e fix: adjust layout styles for better responsiveness 2025-08-30 23:04:55 +08:00
codytseng
cbac9c5464 fix: update regex 2025-08-30 22:23:51 +08:00
codytseng
13527a3ca7 feat: add try-delete post option 2025-08-30 14:21:35 +08:00
codytseng
905ef99e0e feat: add ErrorBoundary component for improved error handling 2025-08-30 13:53:58 +08:00
codytseng
9759e3e750 feat: default to publishing posts only to outbox/inbox relays 2025-08-30 13:06:41 +08:00
codytseng
489ce67343 chore: rename broadcast text to republish 2025-08-29 23:33:19 +08:00
codytseng
8b1f11d2ab feat: add post button to relayInfo component 2025-08-29 22:59:00 +08:00
codytseng
8c9416a6c8 fix: unable to publish to currently browsed relay 2025-08-29 22:35:54 +08:00
codytseng
1fb2c82bd5 feat: add indicator for temporary changes in kind filter 2025-08-29 16:51:00 +08:00
codytseng
35df916a19 feat: 💨 2025-08-28 22:58:47 +08:00
codytseng
3878b84f4c fix: improve active state logic for navigation buttons 2025-08-28 21:48:36 +08:00
codytseng
3dd0ecd970 feat: add profile menu item to sidebar 2025-08-28 21:48:14 +08:00
codytseng
cdd35b447c fix: reduce long press duration 2025-08-27 23:01:26 +08:00
codytseng
7da4127652 fix: add missing key 2025-08-27 22:02:35 +08:00
codytseng
8b1c2ebe3f feat: update layout 2025-08-27 21:56:46 +08:00
codytseng
f41536a793 fix: 🐛 2025-08-27 15:05:47 +08:00
codytseng
d90f6f8261 fix: prevent click event propagation on like button 2025-08-27 11:53:21 +08:00
codytseng
e056f61b4a feat: change like trigger from click to long-press 2025-08-25 23:12:03 +08:00
codytseng
7e02081c55 feat: improve custom emoji fallback logic 2025-08-25 20:45:23 +08:00
codytseng
1fc3632d6e fix: 🐛 2025-08-25 20:37:24 +08:00
codytseng
ac96022178 feat: 💨 2025-08-25 15:53:38 +08:00
codytseng
bd33366342 fix: memoize components to prevent NostrNode 2025-08-25 15:52:26 +08:00
codytseng
9aa63fd11c fix: resolve zap profile issue 2025-08-25 14:27:01 +08:00
codytseng
c04b84a5b6 feat: 💨 2025-08-25 12:06:17 +08:00
codytseng
68d0c85dd2 fix: adjust padding for tab indicator 2025-08-25 12:06:17 +08:00
bitcoinuser
a33a54b1de feat: update pt-BR.ts (#490) 2025-08-25 09:38:56 +08:00
codytseng
9ae604518a chore: update package-locker.json 2025-08-25 09:13:18 +08:00
fiatjaf_
267065ef29 chore: nostr-tools zap api breaking change. (#492) 2025-08-25 09:12:14 +08:00
codytseng
6b88da3f03 feat: support for video events 2025-08-24 16:24:35 +08:00
codytseng
d6a5a82cf8 refactor: url parser 2025-08-24 15:54:13 +08:00
codytseng
c53429fa6c feat: 💨 2025-08-24 10:44:21 +08:00
bitcoinuser
8fa788e88c feat: update pt-BR.ts (#485) 2025-08-24 10:35:32 +08:00
codytseng
ad3df9b037 fix: 💨 2025-08-23 23:21:57 +08:00
codytseng
a9caf9d5ad fix: reset persistence state on open change 2025-08-23 22:48:29 +08:00
codytseng
881d603168 chore: i18n 2025-08-23 22:37:09 +08:00
codytseng
4b9ead8319 feat: kind filter 2025-08-23 22:23:35 +08:00
codytseng
f3f72e2f28 fix: include own notes in following feed 2025-08-22 21:25:04 +08:00
codytseng
71d4420604 feat: custom emoji 2025-08-22 21:05:44 +08:00
codytseng
481d6a1447 feat: 💨 2025-08-20 21:35:57 +08:00
codytseng
f8735348b5 feat: adjust video player size 2025-08-17 21:43:46 +08:00
codytseng
5647eeb1a8 fix: 🐛 2025-08-17 18:35:01 +08:00
codytseng
4e40662f22 feat: support dnd to reorder favorite relays 2025-08-17 18:33:52 +08:00
codytseng
9bdee807ee feat: support dnd to reorder relay sets 2025-08-17 18:04:08 +08:00
codytseng
a7c4d1e450 feat: support dnd to reorder mailbox relays 2025-08-17 17:01:21 +08:00
codytseng
2d237866fb feat: remind users to optimize relay settings 2025-08-17 16:28:15 +08:00
codytseng
6350ddc224 feat: improve highlight source display and navigation 2025-08-16 22:33:19 +08:00
codytseng
06adcdb2a8 feat: improve support for long-form articles 2025-08-16 17:49:18 +08:00
codytseng
6df352a2ab feat: 💨 2025-08-16 16:36:11 +08:00
codytseng
32ec3af729 feat: 💨 2025-08-14 22:57:43 +08:00
codytseng
cb2ad30b1d feat: add toggle to show NSFW content by default 2025-08-14 22:56:44 +08:00
codytseng
352eecc416 feat: initialize default configuration for new users 2025-08-14 22:25:35 +08:00
codytseng
46bf0ecc0a fix: reset follow list and mute list when switching accounts 2025-08-13 23:31:35 +08:00
codytseng
9c3d9a5432 feat: add confirmation prompts for creating new follow and mute lists 2025-08-13 23:23:10 +08:00
codytseng
71cf05c820 feat: add clipboard text serializer to parse editor content 2025-08-13 23:00:59 +08:00
codytseng
836005aaff fix: update ScrollBar to prevent pointer events 2025-08-12 11:14:44 +08:00
codytseng
7cf475e057 fix: 🐛 2025-08-12 11:09:48 +08:00
codytseng
4a946e4ab0 fix: 🐛 2025-08-12 11:05:24 +08:00
codytseng
c6a5157fe7 feat: 💨 2025-08-12 10:50:27 +08:00
Taxil Kathiriya
9969ab2414 feat: add media upload progress bar (#479) 2025-08-12 09:42:57 +08:00
codytseng
e78e2c2078 refactor: note list component 2025-08-11 22:34:48 +08:00
codytseng
96ed0757de feat: improve QR code for easier scanning 2025-08-11 22:14:01 +08:00
Daniel Vergara
fdc5757c63 feat: add support for youtube lives previews (#468) 2025-08-10 16:12:00 +08:00
codytseng
b8b9c976f6 feat: highlight reply button in blue when user has replied 2025-08-10 15:22:50 +08:00
codytseng
081b9b43a8 fix: improve text wrapping in LongFormArticle component 2025-08-10 00:41:16 +08:00
codytseng
f94df67ad8 fix: 🐛 2025-08-09 13:05:59 +08:00
Cody Tseng
f2c87b8d5f feat: add more note interactions lists (#467)
Co-authored-by: Trevor Arjeski <tmarjeski@gmail.com>
2025-08-09 00:49:32 +08:00
codytseng
da78aa63ef fix: remove custom img component in LongFormArticle 2025-08-08 17:45:09 +08:00
codytseng
3950cbd9e6 feat: long form articles 2025-08-07 23:10:04 +08:00
codytseng
0f16ed8d46 💨 2025-08-07 23:06:43 +08:00
codytseng
3b3a3f41c3 fix: prevent flicker of top tabs on back navigation 2025-08-07 09:46:17 +08:00
codytseng
2f4f4fffcf fix: 🐛 2025-08-07 00:24:03 +08:00
codytseng
83052a2ba8 fix: replaceable event coordinate 2025-08-06 17:14:55 +08:00
codytseng
9dd38ac439 fix: adjust padding and alignment in Tabs component 2025-08-04 23:07:02 +08:00
codytseng
21f09426cf refactor: client service 2025-08-04 22:53:36 +08:00
codytseng
5afbdd0aa4 feat: add big relays when fetching note stats 2025-08-04 10:09:53 +08:00
fiatjaf_
b356115c9b fix: default to ws:// instead of wss:// for localhost (#465) 2025-08-04 09:05:20 +08:00
codytseng
f9cbcda7e8 feat: check login status before saving relays 2025-08-03 15:13:47 +08:00
codytseng
ba4f6b382c feat: show search input in emoji picker 2025-08-03 01:18:57 +08:00
codytseng
2e1db46e4a fix: 🐛 2025-08-02 16:22:45 +08:00
codytseng
3f8a9e8efa feat: broadcast 2025-08-02 16:04:27 +08:00
codytseng
5714fae7bd feat: adjust aspect ratio for YouTube shorts 2025-08-01 11:23:15 +08:00
Daniel Vergara
3cd86bb1da feat(components): add support for youtube shorts (#463) 2025-08-01 09:25:29 +08:00
codytseng
63936589d8 fix: resolve user search returning no results 2025-07-31 23:20:36 +08:00
codytseng
5107ce4b77 feat: distinguish between mention and reply notifications 2025-07-31 23:07:57 +08:00
codytseng
629ad3f7cd feat: remove picture tab 2025-07-30 23:17:55 +08:00
codytseng
811388dd2e style: 🎨 2025-07-30 22:40:50 +08:00
codytseng
ed4800de6e feat: display remaining time instead of total duration in AudioPlayer 2025-07-30 22:23:43 +08:00
codytseng
e69395dca0 feat: render youtube player after initialization and enable default mute 2025-07-30 18:14:20 +08:00
codytseng
663885712b fix: update YouTube URL regex 2025-07-30 11:48:54 +08:00
codytseng
0a40f2d916 🐛 2025-07-30 11:26:30 +08:00
codytseng
71b6418dfa fix: 🐛 2025-07-30 11:17:36 +08:00
codytseng
63e9d5a95e fix: 🐛 2025-07-30 10:58:38 +08:00
Cody Tseng
5a28233856 feat: add YouTube embedded player (#460)
Co-authored-by: Daniel  Vergara <daniel.omar.vergara@gmail.com>
2025-07-30 10:55:11 +08:00
codytseng
830362b941 fix: improve reply detection logic 2025-07-30 09:26:53 +08:00
codytseng
4ea5ea1705 feat: audio 2025-07-29 22:44:43 +08:00
codytseng
de09942124 feat: support multiple files upload 2025-07-28 22:41:34 +08:00
codytseng
bcd149b304 feat: poll response notification 2025-07-28 22:33:51 +08:00
codytseng
20c712c56b feat: allow poll creators to view results without voting 2025-07-28 21:44:54 +08:00
codytseng
c697e8629d feat: add translation support for polls 2025-07-28 21:07:50 +08:00
codytseng
cd2c48dd5c fix: adjust textarea height for poll input 2025-07-28 11:25:45 +08:00
codytseng
f480c9e080 fix: 🐛 2025-07-27 23:14:29 +08:00
codytseng
618cc5e826 fix: 🐛 2025-07-27 22:55:58 +08:00
codytseng
1b8adce5fb feat: add p tag to poll response event 2025-07-27 22:51:56 +08:00
codytseng
243cb347d1 style: update MentionNode styling 2025-07-27 22:24:25 +08:00
codytseng
5baa0b0ce3 feat: 💨 2025-07-27 22:21:19 +08:00
codytseng
4286551b12 feat: poll content preview 2025-07-27 22:12:37 +08:00
codytseng
ea821fd708 feat: auto-load poll results 2025-07-27 22:08:08 +08:00
Cody Tseng
b35e0cf850 feat: polls (#451)
Co-authored-by: silberengel <silberengel7@protonmail.com>
2025-07-27 12:05:50 +08:00
codytseng
636ceacdad fix: prevent frequent requests for signing seen notifications events 2025-07-24 22:57:49 +08:00
codytseng
e2d2c27149 fix: add truncation to label container for better UI layout 2025-07-24 09:32:22 +08:00
codytseng
c511f5cb5a refactor: extract tag building functions 2025-07-22 23:10:02 +08:00
codytseng
6d5d4d36c1 feat: add Persian language support 2025-07-20 21:47:10 +08:00
codytseng
78725c1d14 refactor 2025-07-19 17:24:51 +08:00
codytseng
28ec943a52 fix: support uploading files with non-Latin characters in filename 2025-07-19 10:47:11 +08:00
codytseng
e91b2648cc feat: 🌸 2025-07-18 23:25:47 +08:00
codytseng
74e04e1c7d fix: 🐛 2025-07-18 16:53:55 +08:00
codytseng
53e9aea82f fix: 🐛 2025-07-16 09:24:14 +08:00
codytseng
a710e40747 feat: show ellipsis if root and parent reply are not consecutive 2025-07-15 22:39:07 +08:00
codytseng
03deb9d375 feat: improve nip05 favicon loading and display effect 2025-07-14 21:51:19 +08:00
codytseng
069ac34b86 fix: 🐛 2025-07-11 23:00:45 +08:00
codytseng
8db655cc37 fix: append new line after file upload placeholder in editor 2025-07-11 11:59:59 +08:00
codytseng
9a5008080a fix: simplify text pasting in editor and insert a new line after upload placeholder 2025-07-11 11:50:35 +08:00
codytseng
67659a302b chore: adjust padding 2025-07-10 18:20:09 +08:00
codytseng
2193c92f16 fix: preserve line breaks when pasting text in editor 2025-07-10 11:51:11 +08:00
codytseng
4cbd35ce7a fix: 🐛 2025-07-10 11:02:36 +08:00
codytseng
2a842ff246 perf: cache following favorite relays 2025-07-09 22:32:41 +08:00
codytseng
3517476984 feat: add cursor zoom-in style to images in gallery 2025-07-09 15:05:00 +08:00
codytseng
f316ef1946 feat: 💨 2025-07-09 14:54:38 +08:00
codytseng
5d1427db31 feat: update client select button style 2025-07-09 14:37:15 +08:00
codytseng
ece003ca4f feat: increase medium avatar size 2025-07-09 14:29:29 +08:00
codytseng
9c2302bbfc fix: add right padding to prevent italic text truncation 2025-07-08 22:43:52 +08:00
codytseng
2596ba9b47 fix: close client selete drawer when clicking outside 2025-07-08 22:30:15 +08:00
codytseng
76720fc72e fix: display more kinds of bookmarks 2025-07-08 22:22:05 +08:00
codytseng
3441cdc021 fix: 🐛 2025-07-08 17:59:22 +08:00
codytseng
0b5f961087 chore: i18n 2025-07-08 16:51:31 +08:00
codytseng
fa0ab9f385 feat: change Favorite label to Unfavorite when relay is already favorited 2025-07-08 15:15:58 +08:00
codytseng
14c05f5ce9 feat: add recommended relays to home page 2025-07-08 15:08:32 +08:00
codytseng
0e24c3b820 feat: update DEFAULT_FAVORITE_RELAYS 2025-07-08 10:53:53 +08:00
codytseng
8c5cc1041b refactor: 💨 2025-07-07 22:34:59 +08:00
codytseng
c729c20771 fix: 💨 2025-07-06 01:01:53 +08:00
captain-stacks
98bf906916 feat: add toggle to hide untrusted posts (#435) 2025-07-06 01:01:53 +08:00
codytseng
9da5e6663e fix: 🐛 2025-07-04 23:35:55 +08:00
codytseng
e53d74edd1 feat: improve user npub QR code card 2025-07-04 21:38:32 +08:00
codytseng
b470ef4857 fix: 🐛 2025-07-04 09:11:50 +08:00
codytseng
af07240df0 feat: add Korean language support 2025-07-03 22:00:19 +08:00
codytseng
18ce08ce07 refactor: optimize note interaction data processing 2025-07-03 21:46:12 +08:00
codytseng
2d2153448d refactor: tabs 2025-07-03 18:00:00 +08:00
codytseng
061f5c942b feat: remove account button 2025-07-03 14:21:46 +08:00
codytseng
6f0dd7b336 fix: ensure indicator style is calculated after tabs are rendered 2025-07-03 10:06:43 +08:00
codytseng
4df8f10ea9 fix: 🐛 2025-07-03 09:53:03 +08:00
codytseng
29692bc9aa feat: 💨 2025-07-02 23:27:15 +08:00
codytseng
0be08c2312 fix: adjust padding for tab indicator calculation 2025-07-02 23:02:15 +08:00
codytseng
656a9a7b15 fix: update 'Not found the note' to 'Note not found' 2025-07-02 22:54:05 +08:00
codytseng
00bb3c712b refactor: Tabs component 2025-07-02 22:52:04 +08:00
codytseng
2489e8098d fix: 🐛 2025-07-01 22:55:59 +08:00
codytseng
f8572d0795 feat: add website field to profile editor 2025-07-01 22:17:44 +08:00
codytseng
78f2011d23 feat: improve private key login 2025-06-30 22:05:55 +08:00
codytseng
c12a67b113 feat: 💨 2025-06-30 14:51:22 +08:00
codytseng
338b1f8a4c fix: hanlde display of overly long usernames 2025-06-30 09:42:43 +08:00
codytseng
d3093a1c4e feat: support translation for profile abount 2025-06-29 14:13:26 +08:00
bitcoinuser
e89cfc03e9 feat: update pt-BR.ts (#418) 2025-06-29 00:42:59 +08:00
hoppe
04426040d5 fix: bunker regex & add addtitonal nostrconnect relay (#417) 2025-06-29 00:42:30 +08:00
codytseng
5d41e31d07 feat: update small screen breakpoint to 768px 2025-06-28 12:30:38 +08:00
codytseng
5df33837ab feat: add nsfw toggle to post editor 2025-06-27 22:55:21 +08:00
codytseng
544d65972a style: 🎨 2025-06-26 23:50:04 +08:00
codytseng
6cc3dd32a5 style: 🎨 2025-06-26 23:29:12 +08:00
codytseng
5619905ae0 feat: nip05 feeds 2025-06-26 23:21:12 +08:00
codytseng
e08172f4a7 feat: update favicon 2025-06-25 23:00:45 +08:00
codytseng
d138f78837 fix: try to correct notification indicator display issue 2025-06-25 22:02:27 +08:00
codytseng
30343254bd fix: 🐛 2025-06-25 11:22:34 +08:00
codytseng
6c2cd0ff42 fix: 🐛 2025-06-25 11:01:53 +08:00
codytseng
cb32439896 fix: stop event propagation when click "Temporarily display this reply" button 2025-06-24 23:25:32 +08:00
codytseng
3d2f19427a feat: add some i18n translations 2025-06-24 23:21:14 +08:00
codytseng
2298254903 feat: add Thai translations for i18n 2025-06-24 23:17:07 +08:00
codytseng
2121ca7556 feat: add some i18n translation 2025-06-24 22:54:44 +08:00
codytseng
a834e76522 style: adjust padding and margin for EmbeddedLNInvoice 2025-06-24 21:59:33 +08:00
Cody Tseng
df9066eae0 feat: translation (#389) 2025-06-23 23:52:21 +08:00
codytseng
e2e115ebeb feat: exclude muted users from mentions by default 2025-06-23 22:15:37 +08:00
codytseng
e6016242ef feat: highlight reactions notifications 2025-06-23 10:29:57 +08:00
bitcoinuser
8203e0eb50 feat: update pt-BR.ts (#402) 2025-06-23 09:56:26 +08:00
hoppe
f66ca6346f feat: reuse existing bunker connection on account switch (#401) 2025-06-23 09:56:08 +08:00
codytseng
4e80d59c1c feat: redirect bech32 paths 2025-06-22 12:57:43 +08:00
bitcoinuser
7794ae2564 feat: update pt-BR.ts (#399) 2025-06-22 11:25:25 +08:00
Melvin Carvalho
7630e81a08 fix: add trailing slash to algo.ttxo.one fixes #369 (#397) 2025-06-22 11:18:30 +08:00
codytseng
195954ed59 fix: 🐛 2025-06-21 13:38:04 +08:00
codytseng
efb53bb906 feat: prevent posting duplicate notes 2025-06-20 22:27:02 +08:00
codytseng
ef2f8b357d refactor: toast 2025-06-19 23:09:07 +08:00
codytseng
697b8e4663 fix: 🐛 2025-06-19 22:33:19 +08:00
codytseng
a41ff092cd feat: 💨 2025-06-19 22:24:34 +08:00
Daniel Vergara
f25b742877 feat: render embedded invoices (#392) 2025-06-19 22:12:06 +08:00
codytseng
d7dc098995 feat: disable mention input and paste rules temporarily 2025-06-18 22:21:46 +08:00
Daniel Vergara
7288f9c58e fix(compose): add environment variables to set URLs in the docker compose file (#388) 2025-06-15 14:27:24 +08:00
Daniel Vergara
b4f972dcbb feat: support configurable CORS proxy server (#383) 2025-06-14 13:16:02 +08:00
codytseng
34a680ae24 fix: remove debug logs from SaveButton component 2025-06-14 11:33:35 +08:00
codytseng
5f2f63696b fix: 🐛 2025-06-14 11:32:35 +08:00
codytseng
c09c002471 feat: 💨 2025-06-12 23:29:12 +08:00
codytseng
d81dc3fe4e fix: 💨 2025-06-09 22:47:12 +08:00
codytseng
f7dccde746 fix: QR code not scannable in dark mode 2025-06-09 21:55:52 +08:00
codytseng
0d42f8687f feat: update profile banner aspect ratio to 3/1 2025-06-09 10:53:49 +08:00
codytseng
717494b4ad style: 💨 2025-06-09 10:07:01 +08:00
codytseng
03096a80d2 fix: adjust tab indicator position to bottom 2025-06-09 09:52:48 +08:00
codytseng
963051e70d feat: hide untrusted content button 2025-06-09 01:08:50 +08:00
codytseng
5913cc3b88 feat: quotes 2025-06-08 14:05:35 +08:00
codytseng
00866fd73a feat: 💨 2025-06-05 17:02:44 +08:00
hoppe
2b82ae78b7 fix: do not save secret (#376) 2025-06-05 09:12:37 +08:00
bitcoinuser
1b41af5a2f feat: update pt-BR.ts (#374) 2025-06-04 23:23:28 +08:00
bitcoinuser
906e1eb910 feat: update pt-BR.ts (#373) 2025-06-04 23:02:58 +08:00
bitcoinuser
4d70009ed9 feat: update pt-BR.ts (#372) 2025-06-04 22:39:26 +08:00
codytseng
b59391f277 feat: add success message after updating mute list 2025-06-04 22:30:01 +08:00
codytseng
ec1692c066 feat: support choosing between public and private mute 2025-06-04 22:09:27 +08:00
codytseng
30a32ca94f feat: followed by 2025-06-04 09:18:45 +08:00
hoppe
74986b1c6e feat: connection initiated by the client (#364) 2025-06-03 22:06:56 +08:00
codytseng
6bfae35f6a fix: 🐛 2025-06-03 10:11:49 +08:00
codytseng
c777a1564e fix: reply count 2025-06-02 12:29:48 +08:00
codytseng
d2ceb16a58 feat: 💨 2025-06-02 01:04:12 +08:00
codytseng
19fc4864cb feat: send contacts events to big relays 2025-06-02 01:03:12 +08:00
codytseng
c17d1b8ab5 feat: hide notifications from untrusted users 2025-06-01 23:00:01 +08:00
codytseng
587038d51a fix: 🐛 2025-06-01 22:04:44 +08:00
codytseng
1bd8ee03b1 feat: hide replies from untrusted users 2025-06-01 22:01:05 +08:00
codytseng
4785efd43c feat: show emoji picker on non-touch devices 2025-05-28 22:04:38 +08:00
codytseng
94e2a446e8 feat: hide emoji picker in post editor 2025-05-27 23:05:26 +08:00
codytseng
9edd61db78 feat: add emoji picker to post editor 2025-05-27 22:39:57 +08:00
codytseng
061e38a78f refactor: modal 2025-05-27 18:16:46 +08:00
codytseng
a431f31a88 feat: add fallback button to view unsupported events on njump.me 2025-05-26 21:51:09 +08:00
codytseng
34c8c06ce9 feat: improve search results for profiles 2025-05-25 21:49:39 +08:00
bitcoinuser
25b465e62a feat: update pt-BR.ts (#357) 2025-05-25 21:18:51 +08:00
codytseng
bf7bb286af feat: add shortcut key support for posting 2025-05-25 16:45:20 +08:00
codytseng
8beb2430b2 feat: support viewing notes related to external content 2025-05-25 16:08:01 +08:00
codytseng
a319204910 feat: display i value 2025-05-25 15:44:19 +08:00
codytseng
01462da65f feat: 💨 2025-05-24 21:20:49 +08:00
codytseng
ffdc6fd0c8 feat: cache not found relay lists to avoid redundant queries 2025-05-24 20:42:46 +08:00
codytseng
7552499a76 feat: add kind:1111 events to feed 2025-05-24 16:53:14 +08:00
codytseng
06f9ea984f feat: add enhanced support for kind:1111 comments 2025-05-24 16:26:28 +08:00
codytseng
78725d1e88 refactor: post editor 2025-05-23 22:47:31 +08:00
codytseng
3d06421acb fix: 💨 2025-05-23 15:01:27 +08:00
Daniel Omar Vergara Pérez
0136515540 feat: add option to copy share link (#349) 2025-05-23 14:47:26 +08:00
codytseng
6ee9cc1fd2 feat: add highlights to feed 2025-05-23 09:58:04 +08:00
codytseng
ade0906534 fix: 💨 2025-05-22 22:55:57 +08:00
codytseng
98d1ccd614 chore: i18n 2025-05-22 22:50:44 +08:00
Cody Tseng
6c91ba9eff feat: highlight (#346) 2025-05-22 22:39:13 +08:00
codytseng
ef0dc9e923 fix: increase timeout duration for liking action to 10 seconds 2025-05-21 22:57:43 +08:00
codytseng
44a9b6ee0e feat: force refresh of user relay list cache when viewing profile 2025-05-21 22:52:43 +08:00
codytseng
2e4602e973 fix: attempt to resolve notification indicator issue 2025-05-21 21:53:42 +08:00
codytseng
5c39413420 feat: bypass IndexedDB cache when viewing user profile 2025-05-20 23:16:49 +08:00
codytseng
e3c8dc838d fix: 🐛 2025-05-20 15:20:54 +08:00
codytseng
8582f28315 fix: 🐛 2025-05-19 23:20:12 +08:00
codytseng
e9eb897f36 fix: attempt to reduce page flicker 2025-05-19 23:11:29 +08:00
codytseng
c85892d6cd feat: ignore untrusted relay list and onion relays 2025-05-19 22:35:53 +08:00
codytseng
8d23c49908 feat: opensats 2025-05-17 13:45:53 +08:00
codytseng
d08e87ec10 fix: 🐛 2025-05-16 23:37:27 +08:00
codytseng
4b09276943 fix: 🐛 2025-05-16 23:35:12 +08:00
codytseng
fa5e198b8a feat: collapse long replies and profile bios 2025-05-16 23:14:03 +08:00
codytseng
e1fbda5efc feat: collapible component 2025-05-16 22:15:32 +08:00
codytseng
12f5d41811 fix: 🐛 2025-05-16 15:49:27 +08:00
codytseng
81b6462b63 feat: show reaction counts and hide top zap/likes in feed 2025-05-16 15:17:13 +08:00
codytseng
ee6c5c6f03 perf: speed up perceived loading of note stats 2025-05-16 14:55:07 +08:00
codytseng
304bbe4f01 fix: replies 2025-05-15 23:38:22 +08:00
codytseng
a6c2decfe3 fix: 🐛 2025-05-15 15:34:04 +08:00
codytseng
46b9f5625b feat: add confirmation prompt when publishing event signed by a different user 2025-05-12 22:37:30 +08:00
codytseng
41527b2813 chore: update nostr-tools 2025-05-12 17:27:03 +08:00
codytseng
4c7ae4c8a8 feat: increase textarea border-radius 2025-05-10 13:34:17 +08:00
bitcoinuser
145ae05354 feat: update pt-BR.ts (#320) 2025-05-10 13:28:07 +08:00
codytseng
4bfdd4f334 feat: support media files upload via paste and drop 2025-05-09 23:23:17 +08:00
Gustavo Mota
533e00d4ee feat: add docker deployment configuration (#311) 2025-05-09 09:25:57 +08:00
codytseng
a537eaad7b feat: prevent auto-scrolling to newly posted reply 2025-05-08 23:28:12 +08:00
codytseng
7b882c72cb feat: add autoplay switch 2025-05-08 23:24:57 +08:00
codytseng
aa24ad83e5 style: resize images and videos for better visuals 2025-05-08 22:34:33 +08:00
codytseng
ee6780dc69 fix: 🐛 2025-05-08 09:52:33 +08:00
codytseng
23f6032808 fix: scroll to top when clicking current nav item 2025-05-08 09:25:10 +08:00
codytseng
bf6b96fd91 fix: remove filtering replies by created_at 2025-05-08 09:21:25 +08:00
codytseng
7a63f475da feat: remove resize event listener 2025-05-07 23:24:29 +08:00
codytseng
55862da1d3 fix: 🐛 2025-05-07 23:14:24 +08:00
codytseng
04c84db0ae fix: 🐛 2025-05-07 22:35:58 +08:00
codytseng
a01c2e71a7 fix: update regex 2025-05-07 16:42:08 +08:00
codytseng
86880af581 fix: 🐛 2025-05-07 11:04:49 +08:00
codytseng
60fca48a72 feat: support closing modal via back button 2025-05-06 23:10:35 +08:00
codytseng
53c8483a3f feat: auto play video 2025-05-03 22:49:13 +08:00
codytseng
9e186ab974 chore: add LICENSE 2025-05-01 17:01:35 +08:00
codytseng
1fc00ed33f fix: remove unnecessary imeta tags 2025-04-29 14:23:36 +08:00
codytseng
f8757c6d25 feat: 🎨 2025-04-29 11:00:02 +08:00
codytseng
3a98629617 feat: collapse long notes 2025-04-28 22:32:52 +08:00
TheMonkeyCoder
3ffad2ed49 feat: handle app version and commit hash retrieval errors (#305) 2025-04-27 22:47:06 +08:00
codytseng
a91eaf668b fix: check the type of supported_nips 2025-04-27 09:45:33 +08:00
codytseng
f5f34e31fa fix: 🐛 2025-04-26 09:38:16 +08:00
codytseng
eb8fe17ae6 fix: 🐛 2025-04-25 09:22:04 +08:00
codytseng
51913a5163 style: 🎨 2025-04-24 22:23:22 +08:00
TheMonkeyCoder
d3d5842804 fix: keep main feed scroll position on primary page navigation (#299) 2025-04-24 22:00:24 +08:00
TheMonkeyCoder
b0b07365be feat: disable zap button on the user's own notes (#300) 2025-04-24 09:28:42 +08:00
codytseng
5473c22c3f fix: 🐛 2025-04-23 23:04:51 +08:00
codytseng
2c9a5b219b feat: emoji reactions 2025-04-22 22:36:53 +08:00
Max Blake
40b487994d feat: update pl.ts (#293) 2025-04-21 21:40:29 +08:00
codytseng
4f92b8775d fix: show images in embedded kind:20 notes 2025-04-21 09:44:54 +08:00
codytseng
c0f48a73bb fix: bookmarks e tag 2025-04-19 10:52:45 +08:00
codytseng
46d48a6d52 refact: bookmarks 2025-04-18 22:53:52 +08:00
M.Abubakar
7876f26d0c feat: bookmarks (#279) 2025-04-18 21:51:36 +08:00
bitcoinuser
085adeb096 feat: update pt-BR.ts (#282) 2025-04-17 22:55:04 +08:00
codytseng
f0fc9a7ccf feat: pass language parameter to nstart-modal 2025-04-17 22:50:31 +08:00
codytseng
319ae5a0ba feat: embedded emoji 2025-04-17 17:09:22 +08:00
Cody Tseng
c40609c8ac fix: prevent old notes from being treated as new (#278) 2025-04-16 11:23:22 +08:00
codytseng
a393a5eb7a fix: 🐛 2025-04-15 23:07:28 +08:00
Cody Tseng
3c81a13acb feat: conversations (#277) 2025-04-15 22:33:53 +08:00
codytseng
1c3e54c895 refactor: new notes button 2025-04-14 12:41:57 +08:00
Ali Raza
8c141bbbe4 feat: improve "show new notes" button (#268) 2025-04-14 09:32:22 +08:00
codytseng
192fd144e8 fix: auto puase when leave pip 2025-04-13 14:35:12 +08:00
codytseng
015932a198 fix: add compatibility for video on Safari 2025-04-13 12:10:58 +08:00
codytseng
92fe2524e5 fix: avoid actively closing PiP mode 2025-04-13 10:59:54 +08:00
codytseng
f7087517e4 refactor: 💨 2025-04-13 10:30:34 +08:00
Alchemist
c95444748a feat: enable picture-in-picture when video is scrolled out of view (#258)
Co-authored-by: Wutche <wuthchechikaome75@gmail.com>
2025-04-13 10:24:39 +08:00
codytseng
4a143d1814 feat: show number of new notifications 2025-04-12 23:04:52 +08:00
codytseng
68f4b1e909 feat: show login modal when clicking notification without being logged in 2025-04-12 17:47:12 +08:00
codytseng
977d6789da feat: 💨 2025-04-12 17:21:07 +08:00
codytseng
30da0319ce feat: sync notifications read time 2025-04-12 17:18:44 +08:00
codytseng
776f290ef9 fix: 🐛 2025-04-12 12:15:36 +08:00
codytseng
ea5b50cf0a feat: select-none 2025-04-12 00:00:45 +08:00
codytseng
a10c9d8ffe feat: make reply clickable 2025-04-11 23:18:54 +08:00
codytseng
74d7f9be29 refactor: subscribe 2025-04-11 22:53:51 +08:00
codytseng
2a4968568a fix: 🐛 2025-04-11 15:20:08 +08:00
codytseng
206bee87d2 feat: update nstart base url 2025-04-11 09:38:28 +08:00
Cody Tseng
0569a1dd26 feat: enhance post content parsing and rendering (#263) 2025-04-10 23:06:28 +08:00
codytseng
e9f8b2166e fix: 🐛 2025-04-10 23:06:09 +08:00
codytseng
47949a81be fix: add missing translations 2025-04-09 09:37:39 +08:00
Max Blake
0f5ebea399 feat: update pl.ts (#261)
missing translation
2025-04-09 09:33:45 +08:00
codytseng
378f05865d Revert "refactor: 💨"
This reverts commit 3c78f94a7e.
2025-04-08 20:18:59 +08:00
codytseng
3c78f94a7e refactor: 💨 2025-04-08 18:09:09 +08:00
codytseng
c5645f78e0 refactor: remove --highlight 2025-04-08 17:27:51 +08:00
codytseng
3be4a93e6f feat: 💨 2025-04-08 16:16:12 +08:00
codytseng
c368e2473e feat: 💨 2025-04-08 16:12:24 +08:00
codytseng
1a38fa7ca9 style: reply content scroll area 2025-04-08 15:26:07 +08:00
codytseng
f697421c1b fix: 🐛 2025-04-08 15:18:02 +08:00
codytseng
b4618e8cd1 feat: i18n 2025-04-08 14:38:14 +08:00
codytseng
bc0fa7f528 feat: support change media upload service 2025-04-08 14:23:16 +08:00
codytseng
2e552c356c feat: support underscores in hashtags 2025-04-07 17:50:56 +08:00
codytseng
ccbce0e317 chore: format 2025-04-06 20:22:49 +08:00
Anthonyushie
3bb34dae66 feat: display the full content of the reply 2025-04-06 20:19:58 +08:00
codytseng
864d8c617f feat: pull relay sets button 2025-04-06 16:21:44 +08:00
codytseng
27ae980f42 chore: update description 2025-04-06 15:39:24 +08:00
codytseng
cd00e5989b feat: fetch more following's favorite relays 2025-04-06 12:31:17 +08:00
codytseng
027aa4691d feat: add shareable url to relay details page 2025-04-06 00:44:20 +08:00
codytseng
1e6e37f5e5 feat: following's favoriate relays 2025-04-06 00:34:32 +08:00
codytseng
328477a1f3 fix: untimely relay sets synchronization 2025-04-05 22:47:16 +08:00
codytseng
2b9de01905 fix: 🐛 2025-04-05 21:08:52 +08:00
codytseng
0c937d97cd feat: only use read relays to subscribe notifications 2025-04-05 20:19:30 +08:00
codytseng
69209957e4 feat: publish favorite relays event to big relays 2025-04-05 19:42:54 +08:00
codytseng
ffd22e27bc fix: 🐛 2025-04-05 15:44:29 +08:00
Cody Tseng
c739d9d28c feat: favorite relays (#250) 2025-04-05 15:31:34 +08:00
codytseng
fab9ff88b5 feat: update url regex 2025-04-05 11:20:05 +08:00
codytseng
e2454405ad fix: only add nostr: for valid nip19 2025-04-05 11:17:37 +08:00
Cody Tseng
84ef234d38 chore: update nostr-tools (#248) 2025-04-03 17:58:59 +08:00
codytseng
124239839c Revert "fix: 🐛"
This reverts commit e0e28194f6.
2025-04-03 16:41:29 +08:00
codytseng
e0e28194f6 fix: 🐛 2025-04-03 14:59:27 +08:00
codytseng
542c964436 💨 2025-03-31 22:52:40 +08:00
codytseng
bf86742976 fix: 🐛 2025-03-29 16:35:15 +08:00
codytseng
b576afb971 fix: 🐛 2025-03-27 23:21:25 +08:00
codytseng
7826364003 fix: website preview card cannot click 2025-03-27 23:05:06 +08:00
codytseng
d24e208f0b feat: outbox model for the following feed 2025-03-27 22:37:06 +08:00
codytseng
df4eb10802 fix: truncate lightning address 2025-03-26 18:24:20 +08:00
codytseng
83239eb6f0 fix: new uesr can't edit profile 2025-03-26 18:20:26 +08:00
codytseng
3ffd6214d8 feat: modify the default relay sets 2025-03-26 14:24:35 +08:00
codytseng
125fb9a3e2 🐛 2025-03-25 18:38:26 +08:00
codytseng
fe0190a674 feat: support login with hex privkey 2025-03-25 18:19:50 +08:00
codytseng
e4b7722c50 fix: 🐛 2025-03-23 00:42:34 +08:00
codytseng
552089b15c feat: send relay list event to big relays 2025-03-22 15:40:37 +08:00
codytseng
a726971324 🎨 2025-03-21 23:41:36 +08:00
codytseng
fd4f077978 fix: truncate 2025-03-19 22:49:03 +08:00
isolabellart
1042952598 feat: add Italian language option
Added choice for Italian
2025-03-15 22:00:41 +08:00
isolabellart
c3dd91abba feat: add Italian translation (#233)
Italian translation
2025-03-15 21:58:38 +08:00
codytseng
5c9f67c7e9 fix: 🐛 2025-03-15 11:37:09 +08:00
codytseng
eb15544195 feat: automatically add nostr: prefix to nip19 stuff 2025-03-14 10:35:39 +08:00
codytseng
1c08bc067d feat: support nprofile lookup 2025-03-13 15:42:46 +08:00
codytseng
f54ab84dd1 fix: 🐛 2025-03-13 14:31:07 +08:00
codytseng
78caabeafc fix: ensure events are sent to mentioned users' read relays 2025-03-13 12:03:38 +08:00
codytseng
24a18e4d7a fix: 🐛 2025-03-12 23:14:07 +08:00
codytseng
759cd73af4 feat: display reaction emojis in notifications 2025-03-12 22:53:25 +08:00
codytseng
9285ecca3d fix: 🐛 2025-03-12 22:33:46 +08:00
codytseng
ddd4d998f4 fix: 🐛 2025-03-12 22:02:39 +08:00
codytseng
9026fac581 feat: 💨 2025-03-12 21:49:52 +08:00
codytseng
b13742b165 fix: 🐛 2025-03-12 17:47:38 +08:00
codytseng
4ad96eab66 feat: NIP-10 2025-03-12 17:23:01 +08:00
codytseng
5f7a99c7b5 feat: remove username from parent note preview 2025-03-09 23:12:53 +08:00
codytseng
3e04111cf4 feat: cache picture draft post 2025-03-09 16:08:34 +08:00
codytseng
b04e628e00 feat: cache post content 2025-03-09 15:28:12 +08:00
codytseng
6c2d3e1a64 fix: 🐛 2025-03-09 11:25:08 +08:00
codytseng
188c366e23 feat: make preview non-clickable 2025-03-09 11:22:41 +08:00
codytseng
bda09badcf feat: do not hide the zap button 2025-03-09 10:57:52 +08:00
codytseng
cd62f26a38 fix: close the drawer after reposting 2025-03-08 22:14:32 +08:00
494 changed files with 46804 additions and 10833 deletions

33
.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
dev-dist
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Git
.gitignore
#Docker files
docker-compose.yml
Dockerfile

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ dev-dist
*.njsproj
*.sln
*.sw?
.vercel

410
AGENTS.md Normal file
View File

@@ -0,0 +1,410 @@
# AGENTS.md
This document is designed to help AI Agents better understand and modify the Jumble project.
## Project Overview
Jumble is a user-friendly Nostr client for exploring relay feeds.
- **Project Name**: Jumble
- **Main Tech Stack**: React 18 + TypeScript + Vite
- **UI Framework**: Tailwind CSS + Radix UI
- **State Management**: Jotai
- **Core Protocol**: Nostr (using nostr-tools)
## Technical Architecture
### Core Dependencies
- **Build Tool**: Vite 5.x
- **Frontend Framework**: React 18.3.x + TypeScript
- **Styling Solution**:
- Tailwind CSS (primary styling framework)
- Radix UI (unstyled component library)
- next-themes (theme management)
- tailwindcss-animate (animations)
- **State Management**: Jotai 2.x
- **Routing**: path-to-regexp (custom routing solution)
- **Rich Text Editor**: TipTap 2.x
- **Nostr Protocol**: nostr-tools 2.x
- **Other Key Libraries**:
- i18next (internationalization)
- dayjs (date handling)
- flexsearch (search)
- qr-code-styling (QR codes)
- yet-another-react-lightbox (image viewer)
### Project Structure
```
jumble/
├── src/
│ ├── components/ # React components
│ │ ├── ui/ # Base UI components (shadcn/ui style)
│ │ └── ... # Other feature components
│ ├── providers/ # React Context Providers
│ ├── services/ # Business logic service layer
│ ├── hooks/ # Custom React Hooks
│ ├── lib/ # Utility functions and libraries
│ ├── types/ # TypeScript type definitions
│ ├── pages/ # Page components
| | ├── primary # Primary page components (Left column)
│ │ └── secondary # secondary page components (Right column)
│ ├── layouts/ # Layout components
│ ├── i18n/ # Internationalization resources
| | ├── locales # Localization files
│ │ └── index.tx # Basic i18n setup
│ ├── assets/ # Static assets
│ ├── App.tsx # App root component
│ ├── PageManager.tsx # Page manager (custom routing logic)
│ ├── routes # Route configuration
| | ├── primary.tsx # Primary routes (Left column)
│ │ └── secondary.tsx # Secondary routes (Right column)
│ └── constants.ts # Constants definition
├── public/ # Public static assets
└── resources/ # Design resources
```
## Development Guide
### Environment Setup
### Environment Setup
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Lint code
npm run lint
# Format code
npm run format
```
## Code Conventions
### Component Development
1. **Component Structure**: Each major feature component is typically in its own folder, containing index.tsx and related sub-components
2. **Styling**: Use Tailwind CSS utility classes, complex components can use class-variance-authority (cva)
3. **Type Safety**: All components should have explicit TypeScript type definitions
4. **State Management**:
- Use Jotai atoms for global state management
- Use Context Providers for cross-component data
### Service Layer (Services)
Service files located in `src/services/` encapsulate business logic:
- `client.service.ts` - Nostr client core logic for fetching and publishing events
- `indexed-db.service.ts` - IndexedDB data storage
- `local-storage.service.ts` - LocalStorage management
- `media-upload.service.ts` - Media upload service
- `translation.service.ts` - Translation service
- `lightning.service.ts` - Lightning Network integration
- `relay-info.service.ts` - Relay information management
- `blossom.service.ts` - Blossom integration
- `custom-emoji.service.ts` - Custom emoji management
- `libre-translate.service.ts` - LibreTranslate API integration
- `media-manager.service.ts` - Managing media play state
- `modal-manager.service.ts` - Managing modal stack for back navigation (ensures modals close one by one before actual page navigation)
- `note-stats.service.ts` - Note statistics storage and retrieval (likes, zaps, reposts)
- `poll-results.service.ts` - Poll results storage and retrieval
- `post-editor-cache.service.ts` - Caching post editor content to prevent data loss
- `web.push.service.ts` - Web metadata fetching for link previews
### Providers Architecture
The app uses a multi-layered Provider nesting structure (see `App.tsx`):
```
ScreenSizeProvider
└─ UserPreferencesProvider
└─ ThemeProvider
└─ ContentPolicyProvider
└─ NostrProvider
└─ ... (more providers)
```
Pay attention to Provider dependencies when modifying functionality.
And some Providers are placed in `PageManager.tsx` because they need to use the `usePrimaryPage` and `useSecondaryPage` hooks.
### Routing System
- Route configuration in `src/routes/primary.tsx` and `src/routes/secondary.tsx`
- Using `PageManager.tsx` to manage page navigation, rendering, and state. Normally, you don't need to modify this file.
- Primary pages (left column) use key-based navigation
- Secondary pages (right column) use path-based navigation with stack support
- More details in "Adding a New Page" section below
### Internationalization (i18n)
Jumble is a multi-language application. When you add new text content, please ensure to add translations for all supported languages as much as possible. Append new translations to the end of each translation file without modifying or removing existing keys.
- Translation files located in `src/i18n/locales/`
- Using `react-i18next` for internationalization
- Supported languages: ar, de, en, es, fa, fr, hi, hu, it, ja, ko, pl, pt-BR, pt-PT, ru, th, zh, zh-TW
#### Adding New Language
1. Create a new file in `src/i18n/locales/` with the language code (e.g., `th.ts` for Thai)
2. According to `src/i18n/locales/en.ts`, add translation key-value pairs
3. Update `src/i18n/index.ts` to include the new language resource
4. Update `detectLanguage` function in `src/lib/utils.ts` to support detecting the new language
## Nostr Protocol Integration
### Core Concepts
- **Events**: Nostr events (notes, profile updates, etc.). All data in Nostr is represented as events. They have different kinds (kinds) to represent different types of data.
- **Relays**: Relay servers, which are WebSocket servers that store and forward Nostr events.
- **NIPs**: Nostr Implementation Proposals
### Supported Event Kinds
I mean kinds that are supported to be displayed in the feed.
- Kind 1: Short Text Note
- Kind 6: Repost
- Kind 20: Picture Note
- Kind 21: Video Note
- Kind 22: Short Video Note
- Kind 1068: Poll
- Kind 1111: Comment
- Kind 1222: Voice Note
- Kind 1244: Voice Comment
- Kind 9802: Highlight
- Kind 30023: Long-Form Article
- Kind 31987: Relay Review
- Kind 34550: Community Definition
- Kind 30311: Live Event
- Kind 39000: Group Metadata
- Kind 30030: Emoji Pack
More details you can find in `src/components/Note/`. If you want to add support for new kinds, you need to create new components under `src/components/Note/` and update `src/components/Note/index.tsx`.
And also you need to update `src/components/ContentPreview/` to support preview rendering for the new kinds. `ContentPreview` is used in various places like parent notes, notifications, highlight sources, etc. It only has one line of text space, so you need to figure out a suitable preview display method for different types of content. Use text only as much as possible.
Please avoid modifying the framework, such as avatars, usernames, timestamps, and action buttons in the `Note` component. Only add content rendering logic for new types.
## Common Components
### src/components/Note
Used to display a Nostr event (note).
Properties:
- `event`: `NoteEvent` - The Nostr event to display
- `hideParentNotePreview`: `boolean` - Whether to hide the parent note preview
- `showFull`: `boolean` - Whether to show the full content of the note. Default is `false`, which shows a truncated version with "Show more" option when content is long.
### src/components/NoteList
Used to display a list of notes with infinite scrolling support.
Properties:
- `subRequests`: `{ urls: string[]; filter: Omit<Filter, 'since' | 'until'> }[]` - Array of Nostr subscription requests to fetch notes
- `urls`: Relay URLs for the subscription
- `filter`: Nostr filter for the subscription (without `since`, `until` and `limit`, which are managed internally)
- `showKinds`: `number[]` - Array of event kinds to display
- `filterMutedNotes`: `boolean` - Whether to filter out muted notes
- `hideReplies`: `boolean` - Whether to hide reply notes
- `hideUntrustedNotes`: `boolean` - Whether to hide notes from untrusted authors
- `filterFn`: `(note: NoteEvent) => boolean` - Custom filter function for notes. Return `true` to display the note, `false` to hide it.
### src/components/Tabs
A tab component for switching between different views.
Properties:
- `tabs`: `{ value: string; label: string }[]` - Array of tab definitions. `value` is the unique identifier for the tab, `label` is the display text. `label` will be passed through `t()` for translation.
- `value`: `string` - Currently selected tab value.
- `onChange`: `(value: string) => void` - Callback function when the selected tab changes.
- `threshold`: `number` - Height threshold for hiding the tab bar on scroll down. Default is `800`. It should larger than the height of the area above the tab bar. Normally you don't need to change this value.
- `options`: `React.ReactNode` - Additional options to display on the right side of the tab bar.
## Common Modification Scenarios
### Adding a New Component
1. Create a component folder in `src/components/`
2. Create `index.tsx` and necessary sub-components
3. Write styles using Tailwind CSS
4. If needed, add base UI components in `src/components/ui/`
### Adding a New Page
#### Adding a Primary Page (Left Column)
Primary pages are the main navigation pages that appear in the left column (or full screen on mobile).
1. **Create the page component**:
```bash
# Create a new folder under src/pages/primary/
mkdir src/pages/primary/YourNewPage
```
2. **Implement the component** (`src/pages/primary/YourNewPage/index.tsx`):
```tsx
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types'
import { forwardRef } from 'react'
const YourNewPage = forwardRef<TPageRef>((_, ref) => {
return (
<PrimaryPageLayout ref={ref} title="Your Page Title" icon={<YourIcon />}>
{/* Your page content */}
</PrimaryPageLayout>
)
})
export default YourNewPage
```
**Important**:
- Primary pages MUST use `forwardRef<TPageRef>`
- Wrap content with `PrimaryPageLayout`
- The ref is used by PageManager for navigation control
3. **Register the route** in `src/routes/primary.tsx`:
```tsx
import YourNewPage from '@/pages/primary/YourNewPage'
const PRIMARY_ROUTE_CONFIGS: RouteConfig[] = [
// ... existing routes
{ key: 'yourNewPage', component: YourNewPage }
]
```
4. **Navigate to the page** using the `usePrimaryPage` hook:
```tsx
import { usePrimaryPage } from '@/PageManager'
const { navigate } = usePrimaryPage()
navigate('yourNewPage')
```
#### Adding a Secondary Page (Right Column)
Secondary pages appear in the right column (or full screen on mobile) and support stack-based navigation.
1. **Create the page component**:
```bash
# Create a new folder under src/pages/secondary/
mkdir src/pages/secondary/YourNewPage
```
2. **Implement the component** (`src/pages/secondary/YourNewPage/index.tsx`):
```tsx
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react'
const YourNewPage = forwardRef(({ index }: { index?: number }, ref) => {
return (
<SecondaryPageLayout ref={ref} index={index} title="Your Page Title">
{/* Your page content */}
</SecondaryPageLayout>
)
})
export default YourNewPage
```
**Important**:
- Secondary pages receive an `index` prop for stack navigation
- Use `SecondaryPageLayout` for consistent styling
- The ref enables navigation control
3. **Register the route** in `src/routes/secondary.tsx`:
```tsx
import YourNewPage from '@/pages/secondary/YourNewPage'
const SECONDARY_ROUTE_CONFIGS = [
// ... existing routes
{ path: '/your-path/:id', element: <YourNewPage /> }
]
```
Add the corresponding path generation function in `src/lib/link.ts` for the new route:
```tsx
export const toYourNewPage = (id: string) => `/your-path/${id}`
```
4. **Navigate to the page**:
```tsx
import { useSecondaryPage } from '@/PageManager'
import { toYourNewPage } from '@/lib/link'
const { push, pop } = useSecondaryPage()
// Navigate to new page
push(toYourNewPage('some-id'))
// Navigate back
pop()
```
5. **Access route parameters**:
```tsx
const YourNewPage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
console.log('Route param id:', id)
// ...
})
```
#### Key Differences
| Aspect | Primary Pages | Secondary Pages |
| -------------- | ----------------------------------- | ------------------------------- |
| **Location** | Left column (main navigation) | Right column (detail view) |
| **Navigation** | Replace-based (`navigate`) | Stack-based (`push`/`pop`) |
| **Layout** | `PrimaryPageLayout` | `SecondaryPageLayout` |
| **Routes** | Key-based (e.g., 'home', 'explore') | Path-based (e.g., '/notes/:id') |
On mobile devices or single-column layouts, primary pages occupy the full screen, while secondary pages are accessed via stack navigation. When navigating to another primary page, it will clear the secondary page stack.
### How to Parse and Render Content
First, use the `parseContent` method in `src/lib/content-parser.ts` to parse the content. It supports passing different parsers to parse only the needed content for different scenarios. You will get an array of `TEmbeddedNode[]`, and render the content according to the type of these nodes in order. If you need to support new node types, you can add new parsing methods in `src/lib/content-parser.ts`. If you want to recognize specific URLs as special types of nodes, you can extend the `EmbeddedUrlParser` method in `src/lib/content-parser.ts`. A complete usage example can be found in `src/components/Content/index.tsx`.
### Adding New State Management
1. For global state, create a new Provider in `src/providers/`
2. Add Provider in `App.tsx` in the correct dependency order
Or create a singleton service in `src/services/` and use Jotai atoms for state management.
### Adding New Business Logic
1. Create a new service file in `src/services/`
2. Export singleton instance
3. Import and use in anywhere needed
### Style Modifications
- Global styles: `src/index.css`
- Tailwind configuration: `tailwind.config.js`
- Component styles: Use Tailwind class names directly

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Step 1: Build the application
FROM node:20-alpine as builder
ARG VITE_PROXY_SERVER
ENV VITE_PROXY_SERVER=${VITE_PROXY_SERVER}
WORKDIR /app
# Copy package files first
COPY package*.json ./
RUN npm install
# Copy the source code to prevent invaliding cache whenever there is a change in the code
COPY . .
RUN npm run build
# Step 2: Final container with Nginx and embedded config
FROM nginx:alpine
# Copy only the generated static files
COPY --from=builder /app/dist /usr/share/nginx/html
# Embed Nginx configuration directly
RUN printf "server {\n\
listen 80;\n\
server_name localhost;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
\n\
location / {\n\
try_files \$uri \$uri/ /index.html;\n\
}\n\
\n\
location ~* \\.(?:js|css|woff2?|ttf|otf|eot|ico|jpg|jpeg|png|gif|svg|webp)\$ {\n\
expires 30d;\n\
access_log off;\n\
add_header Cache-Control \"public\";\n\
}\n\
\n\
gzip on;\n\
gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/json;\n\
gzip_proxied any;\n\
gzip_min_length 1024;\n\
gzip_comp_level 6;\n\
}\n" > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT LICENSE
Copyright (c) 2025 Cody Tseng
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
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.

View File

@@ -1,7 +1,5 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./resources/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="./resources/logo-light.svg">
<img src="./resources/logo-light.svg" alt="Jumble Logo" width="400" />
</picture>
<p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p>
@@ -9,23 +7,17 @@
# Jumble
A beautiful nostr client focused on browsing relay feeds
A user-friendly Nostr client for exploring relay feeds
## Features
Experience Jumble at [https://jumble.social](https://jumble.social)
- **Relay Feeds:** Explore content directly through relays without following specific users
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
- **Relay Sets:** Easily manage and switch between relay sets
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions
## Forks
## Screenshots
> Some interesting forks of Jumble.
<img src="./screenshots/01.png" alt="Jumble Screenshot 01" width="650" />
<div>
<img src="./screenshots/02.png" alt="Jumble Screenshot 02" width="200" />
<img src="./screenshots/03.png" alt="Jumble Screenshot 03" width="200" />
<img src="./screenshots/04.png" alt="Jumble Screenshot 04" width="200" />
</div>
- [https://fevela.me/](https://fevela.me/) - by [@daniele](https://jumble.social/users/npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk)
- [https://x21.com/](https://x21.com/) - by [@Karnage](https://jumble.social/users/npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac)
- [https://jumble.imwald.eu/](https://jumble.imwald.eu/) Repo: [Silberengel/jumble](https://github.com/Silberengel/jumble) - by [@Silberengel](https://jumble.social/users/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z)
## Run Locally
@@ -43,15 +35,34 @@ npm install
npm run dev
```
## Run Docker
```bash
# Clone this repository
git clone https://github.com/CodyTseng/jumble.git
# Go into the repository
cd jumble
# Run the docker compose
docker compose up --build -d
```
After finishing, access: http://localhost:8089
## Sponsors
<a target="_blank" href="https://opensats.org/">
<img alt="open-sats-logo" src="./resources/open-sats-logo.svg" height="44">
</a>
## Donate
If you like this project, you can buy me a coffee :)
lightning: ⚡️ codytseng@getalby.com ⚡️
bitcoin: bc1qx8kvutghdhejx7vuvatmvw2ghypdungu0qm7ds
geyser: https://geyser.fund/project/jumble
- **Lightning:** ⚡️ codytseng@getalby.com ⚡️
- **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht
- **Geyser:** https://geyser.fund/project/jumble
## License

44
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,44 @@
services:
jumble:
container_name: jumble-nginx
build:
context: .
dockerfile: Dockerfile
args:
VITE_PROXY_SERVER: ${JUMBLE_PROXY_SERVER_URL:-http://localhost:8090}
ports:
- '8089:80'
restart: unless-stopped
networks:
- jumble
proxy-server:
image: ghcr.io/danvergara/jumble-proxy-server:latest
environment:
- ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089}
- JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN}
- ENABLE_PPROF=true
- PORT=8080
ports:
- '8090:8080'
networks:
- jumble
nostr-relay:
image: scsibug/nostr-rs-relay:latest
container_name: jumble-nostr-relay
ports:
- '7000:8080'
environment:
- RUST_LOG=warn,nostr_rs_relay=info
volumes:
- relay-data:/usr/src/app/db
networks:
- jumble
restart: unless-stopped
volumes:
relay-data:
networks:
jumble:

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
version: '3.8'
services:
jumble:
container_name: jumble-nginx
build:
context: .
dockerfile: Dockerfile
args:
VITE_PROXY_SERVER: ${JUMBLE_PROXY_SERVER_URL:-http://localhost:8090}
ports:
- '8089:80'
restart: unless-stopped
networks:
- jumble
proxy-server:
image: ghcr.io/danvergara/jumble-proxy-server:latest
environment:
- ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089}
- JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN}
- ENABLE_PPROF=true
- PORT=8080
ports:
- '8090:8080'
networks:
- jumble
networks:
jumble:

View File

@@ -5,17 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Jumble</title>
<meta name="description" content="A beautiful nostr client focused on browsing relay feeds" />
<meta name="description" content="A user-friendly Nostr client for exploring relay feeds" />
<meta
name="keywords"
content="jumble, nostr, web, client, relay, feed, social, pwa, simple, clean"
/>
<meta name="apple-mobile-web-app-title" content="Jumble" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
<meta property="og:url" content="https://jumble.social" />
@@ -23,7 +22,7 @@
<meta property="og:title" content="Jumble" />
<meta
property="og:description"
content="A beautiful nostr client focused on browsing relay feeds"
content="A user-friendly Nostr client for exploring relay feeds"
/>
<meta
property="og:image"

3665
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "jumble",
"version": "0.1.0",
"description": "A beautiful nostr client focused on browsing relay feeds",
"description": "A user-friendly Nostr client for exploring relay feeds",
"private": true,
"type": "module",
"author": "codytseng",
@@ -19,46 +19,74 @@
"preview": "vite preview"
},
"dependencies": {
"@getalby/bitcoin-connect-react": "^3.7.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0",
"@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@tailwindcss/typography": "^0.5.16",
"@tiptap/extension-emoji": "^2.26.1",
"@tiptap/extension-history": "^2.12.0",
"@tiptap/extension-mention": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@tiptap/react": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@webbtc/webln-types": "^3.0.0",
"blossom-client-sdk": "^4.1.0",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dataloader": "^2.2.3",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.5.1",
"embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.1.0",
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43",
"franc-min": "^6.2.0",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"jotai": "^2.15.0",
"lru-cache": "^11.0.2",
"lucide-react": "^0.469.0",
"nostr-tools": "^2.10.4",
"nstart-modal": "^1.4.0",
"next-themes": "^0.4.6",
"nostr-tools": "^2.17.0",
"nstart-modal": "^2.0.0",
"path-to-regexp": "^8.2.0",
"qrcode.react": "^4.2.0",
"qr-code-styling": "^1.9.2",
"qr-scanner": "^1.4.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.2.0",
"react-markdown": "^10.1.0",
"react-simple-pull-to-refresh": "^1.3.3",
"react-string-replace": "^1.1.1",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.5",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"thumbhash": "^0.1.1",
"tippy.js": "^6.3.7",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"yet-another-react-lightbox": "^3.21.7",
"zod": "^3.24.1"
@@ -68,6 +96,7 @@
"@types/node": "^22.10.2",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/uri-templates": "^0.1.34",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",

View File

@@ -0,0 +1,7 @@
{
"names": {
"_": "f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a",
"cody": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883",
"cody2": "24462930821b45f530ec0063eca0a6522e5a577856f982fa944df0ef3caf03ab"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 B

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

10
resources/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

View File

@@ -1,39 +1,69 @@
import 'yet-another-react-lightbox/styles.css'
import './index.css'
import { Toaster } from '@/components/ui/toaster'
import { Toaster } from '@/components/ui/sonner'
import { BookmarksProvider } from '@/providers/BookmarksProvider'
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider'
import { FollowListProvider } from '@/providers/FollowListProvider'
import { KindFilterProvider } from '@/providers/KindFilterProvider'
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
import { MuteListProvider } from '@/providers/MuteListProvider'
import { NostrProvider } from '@/providers/NostrProvider'
import { PinListProvider } from '@/providers/PinListProvider'
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
import { ThemeProvider } from '@/providers/ThemeProvider'
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider'
import { UserTrustProvider } from '@/providers/UserTrustProvider'
import { ZapProvider } from '@/providers/ZapProvider'
import { PageManager } from './PageManager'
import { FeedProvider } from './providers/FeedProvider'
import { FollowListProvider } from './providers/FollowListProvider'
import { MuteListProvider } from './providers/MuteListProvider'
import { NostrProvider } from './providers/NostrProvider'
import { NoteStatsProvider } from './providers/NoteStatsProvider'
import { RelaySetsProvider } from './providers/RelaySetsProvider'
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
import { ZapProvider } from './providers/ZapProvider'
export default function App(): JSX.Element {
return (
<ThemeProvider>
<ScreenSizeProvider>
<NostrProvider>
<ZapProvider>
<RelaySetsProvider>
<FollowListProvider>
<MuteListProvider>
<FeedProvider>
<NoteStatsProvider>
<PageManager />
<Toaster />
</NoteStatsProvider>
</FeedProvider>
</MuteListProvider>
</FollowListProvider>
</RelaySetsProvider>
</ZapProvider>
</NostrProvider>
</ScreenSizeProvider>
</ThemeProvider>
<ScreenSizeProvider>
<UserPreferencesProvider>
<ThemeProvider>
<ContentPolicyProvider>
<DeletedEventProvider>
<NostrProvider>
<ZapProvider>
<TranslationServiceProvider>
<FavoriteRelaysProvider>
<FollowListProvider>
<MuteListProvider>
<UserTrustProvider>
<BookmarksProvider>
<EmojiPackProvider>
<PinListProvider>
<PinnedUsersProvider>
<FeedProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</FeedProvider>
</PinnedUsersProvider>
</PinListProvider>
</EmojiPackProvider>
</BookmarksProvider>
</UserTrustProvider>
</MuteListProvider>
</FollowListProvider>
</FavoriteRelaysProvider>
</TranslationServiceProvider>
</ZapProvider>
</NostrProvider>
</DeletedEventProvider>
</ContentPolicyProvider>
</ThemeProvider>
</UserPreferencesProvider>
</ScreenSizeProvider>
)
}

View File

@@ -1,8 +1,6 @@
import Sidebar from '@/components/Sidebar'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import NoteListPage from '@/pages/primary/NoteListPage'
import HomePage from '@/pages/secondary/HomePage'
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
import { TPageRef } from '@/types'
import {
cloneElement,
@@ -12,20 +10,26 @@ import {
RefObject,
useContext,
useEffect,
useRef,
useState
} from 'react'
import ExplorePage from './pages/primary/ExplorePage'
import MePage from './pages/primary/MePage'
import NotificationListPage from './pages/primary/NotificationListPage'
import BackgroundAudio from './components/BackgroundAudio'
import BottomNavigationBar from './components/BottomNavigationBar'
import CreateWalletGuideToast from './components/CreateWalletGuideToast'
import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
import { normalizeUrl } from './lib/url'
import { NotificationProvider } from './providers/NotificationProvider'
import { useScreenSize } from './providers/ScreenSizeProvider'
import { routes } from './routes'
export type TPrimaryPageName = keyof typeof PRIMARY_PAGE_MAP
import { useTheme } from './providers/ThemeProvider'
import { useUserPreferences } from './providers/UserPreferencesProvider'
import { PRIMARY_PAGE_MAP, PRIMARY_PAGE_REF_MAP, TPrimaryPageName } from './routes/primary'
import { SECONDARY_ROUTES } from './routes/secondary'
import modalManager from './services/modal-manager.service'
type TPrimaryPageContext = {
navigate: (page: TPrimaryPageName) => void
navigate: (page: TPrimaryPageName, props?: object) => void
current: TPrimaryPageName | null
display: boolean
}
type TSecondaryPageContext = {
@@ -37,24 +41,10 @@ type TSecondaryPageContext = {
type TStackItem = {
index: number
url: string
component: React.ReactElement | null
element: React.ReactElement | null
ref: RefObject<TPageRef> | null
}
const PRIMARY_PAGE_REF_MAP = {
home: createRef<TPageRef>(),
explore: createRef<TPageRef>(),
notifications: createRef<TPageRef>(),
me: createRef<TPageRef>()
}
const PRIMARY_PAGE_MAP = {
home: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.home} />,
explore: <ExplorePage ref={PRIMARY_PAGE_REF_MAP.explore} />,
notifications: <NotificationListPage ref={PRIMARY_PAGE_REF_MAP.notifications} />,
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />
}
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
@@ -78,7 +68,7 @@ export function useSecondaryPage() {
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
const [primaryPages, setPrimaryPages] = useState<
{ name: TPrimaryPageName; element: ReactNode }[]
{ name: TPrimaryPageName; element: ReactNode; props?: any }[]
>([
{
name: 'home',
@@ -87,8 +77,44 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
])
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const { isSmallScreen } = useScreenSize()
const { themeSetting } = useTheme()
const { enableSingleColumnLayout } = useUserPreferences()
const ignorePopStateRef = useRef(false)
useEffect(() => {
if (isSmallScreen) return
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
navigatePrimaryPage('search')
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [isSmallScreen])
useEffect(() => {
if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) {
window.history.replaceState(
null,
'',
'/users' + window.location.pathname + window.location.search + window.location.hash
)
} else if (
['/note1', '/nevent1', '/naddr1'].some((prefix) =>
window.location.pathname.startsWith(prefix)
)
) {
window.history.replaceState(
null,
'',
'/notes' + window.location.pathname + window.location.search + window.location.hash
)
}
window.history.pushState(null, '', window.location.href)
if (window.location.pathname !== '/') {
const url = window.location.pathname + window.location.search + window.location.hash
setSecondaryStack((prevStack) => {
@@ -105,9 +131,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
return newStack
})
} else {
const searchParams = new URLSearchParams(window.location.search)
const r = searchParams.get('r')
if (r) {
const url = normalizeUrl(r)
if (url) {
navigatePrimaryPage('relay', { url })
}
}
}
const onPopState = (e: PopStateEvent) => {
if (ignorePopStateRef.current) {
ignorePopStateRef.current = false
return
}
const closeModal = modalManager.pop()
if (closeModal) {
ignorePopStateRef.current = true
window.history.forward()
return
}
let state = e.state as { index: number; url: string } | null
setSecondaryStack((pre) => {
const currentItem = pre[pre.length - 1] as TStackItem | undefined
@@ -129,10 +176,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
if (state.index === currentIndex) {
if (currentIndex !== 0) return pre
window.history.replaceState(null, '', '/')
return []
return pre
}
// Go back
@@ -140,20 +184,20 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const topItem = newStack[newStack.length - 1] as TStackItem | undefined
if (!topItem) {
// Create a new stack item if it's not exist (e.g. when the user refreshes the page, the stack will be empty)
const { component, ref } = findAndCreateComponent(state.url, state.index)
if (component) {
const { element, ref } = findAndCloneElement(state.url, state.index)
if (element) {
newStack.push({
index: state.index,
url: state.url,
component,
element,
ref
})
}
} else if (!topItem.component) {
// Load the component if it's not cached
const { component, ref } = findAndCreateComponent(topItem.url, state.index)
if (component) {
topItem.component = component
} else if (!topItem.element) {
// Load the element if it's not cached
const { element, ref } = findAndCloneElement(topItem.url, state.index)
if (element) {
topItem.element = element
topItem.ref = ref
}
}
@@ -171,14 +215,23 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
}, [])
const navigatePrimaryPage = (page: TPrimaryPageName) => {
const exists = primaryPages.find((p) => p.name === page)
if (!exists) {
setPrimaryPages((prev) => [...prev, { name: page, element: PRIMARY_PAGE_MAP[page] }])
}
const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => {
const needScrollToTop = page === currentPrimaryPage
setPrimaryPages((prev) => {
const exists = prev.find((p) => p.name === page)
if (exists && props) {
exists.props = props
return [...prev]
} else if (!exists) {
return [...prev, { name: page, element: PRIMARY_PAGE_MAP[page], props }]
}
return prev
})
setCurrentPrimaryPage(page)
PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop()
if (isSmallScreen) {
if (needScrollToTop) {
PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth')
}
if (enableSingleColumnLayout) {
clearSecondaryPages()
}
}
@@ -188,7 +241,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isCurrentPage(prevStack, url)) {
const currentItem = prevStack[prevStack.length - 1]
if (currentItem?.ref?.current) {
currentItem.ref.current.scrollToTop()
currentItem.ref.current.scrollToTop('instant')
}
return prevStack
}
@@ -201,13 +254,19 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})
}
const popSecondaryPage = () => {
window.history.go(-1)
const popSecondaryPage = (delta = -1) => {
if (secondaryStack.length <= -delta) {
// back to home page
window.history.replaceState(null, '', '/')
setSecondaryStack([])
} else {
window.history.go(delta)
}
}
const clearSecondaryPages = () => {
if (secondaryStack.length === 0) return
window.history.go(-secondaryStack.length)
popSecondaryPage(-secondaryStack.length)
}
if (isSmallScreen) {
@@ -215,7 +274,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: secondaryStack.length === 0 ? currentPrimaryPage : null
current: currentPrimaryPage,
display: secondaryStack.length === 0
}}
>
<SecondaryPageContext.Provider
@@ -227,30 +287,97 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
: 0
}}
>
<NotificationProvider>
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<CurrentRelaysProvider>
<NotificationProvider>
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.element}
</div>
))}
{primaryPages.map(({ name, element, props }) => (
<div
key={item.index}
key={name}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
display:
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
}}
>
{item.component}
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
{primaryPages.map(({ name, element }) => (
<div
key={name}
style={{
display:
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
}}
>
{element}
<BottomNavigationBar />
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
if (enableSingleColumnLayout) {
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: secondaryStack.length === 0
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<div className="flex lg:justify-around w-full">
<div className="sticky top-0 lg:w-full flex justify-end self-start h-[var(--vh)]">
<Sidebar />
</div>
<div className="flex-1 w-0 bg-background border-x lg:flex-auto lg:w-[640px] lg:shrink-0">
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.element}
</div>
))}
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
style={{
display:
secondaryStack.length === 0 && currentPrimaryPage === name
? 'block'
: 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
</div>
<div className="hidden lg:w-full lg:block" />
</div>
))}
</NotificationProvider>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
@@ -260,7 +387,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage
current: currentPrimaryPage,
display: true
}}
>
<SecondaryPageContext.Provider
@@ -270,40 +398,66 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
}}
>
<NotificationProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<Separator orientation="vertical" />
<div className="grid grid-cols-2 w-full">
<div className="flex border-r">
{primaryPages.map(({ name, element }) => (
<CurrentRelaysProvider>
<NotificationProvider>
<div className="flex flex-col items-center bg-surface-background">
<div
className="flex h-[var(--vh)] w-full bg-surface-background"
style={{
maxWidth: '1920px'
}}
>
<Sidebar />
<div
className={cn(
'grid grid-cols-2 w-full',
themeSetting === 'pure-black' ? '' : 'gap-2 pr-2 py-2'
)}
>
<div
key={name}
className="w-full"
style={{
display: currentPrimaryPage === name ? 'block' : 'none'
}}
className={cn(
'bg-background overflow-hidden',
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl shadow-lg'
)}
>
{element}
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
className="flex flex-col h-full w-full"
style={{
display: currentPrimaryPage === name ? 'block' : 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
</div>
))}
</div>
<div>
{secondaryStack.map((item, index) => (
<div
key={item.index}
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
className={cn(
'bg-background overflow-hidden',
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl',
themeSetting !== 'pure-black' && secondaryStack.length > 0 && 'shadow-lg',
secondaryStack.length === 0 ? 'bg-surface' : ''
)}
>
{item.component}
{secondaryStack.map((item, index) => (
<div
key={item.index}
className="flex flex-col h-full w-full"
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
>
{item.element}
</div>
))}
</div>
))}
<div key="home" style={{ display: secondaryStack.length === 0 ? 'block' : 'none' }}>
<HomePage />
</div>
</div>
</div>
</div>
</NotificationProvider>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
@@ -344,15 +498,15 @@ function isCurrentPage(stack: TStackItem[], url: string) {
return currentPage.url === url
}
function findAndCreateComponent(url: string, index: number) {
function findAndCloneElement(url: string, index: number) {
const path = url.split('?')[0].split('#')[0]
for (const { matcher, element } of routes) {
for (const { matcher, element } of SECONDARY_ROUTES) {
const match = matcher(path)
if (!match) continue
if (!element) return {}
const ref = createRef<TPageRef>()
return { component: cloneElement(element, { ...match.params, index, ref } as any), ref }
return { element: cloneElement(element, { ...match.params, index, ref } as any), ref }
}
return {}
}
@@ -366,15 +520,15 @@ function pushNewPageToStack(
const currentItem = stack[stack.length - 1]
const currentIndex = specificIndex ?? (currentItem ? currentItem.index + 1 : 0)
const { component, ref } = findAndCreateComponent(url, currentIndex)
if (!component) return { newStack: stack, newItem: null }
const { element, ref } = findAndCloneElement(url, currentIndex)
if (!element) return { newStack: stack, newItem: null }
const newItem = { component, ref, url, index: currentIndex }
const newItem = { element, ref, url, index: currentIndex }
const newStack = [...stack, newItem]
const lastCachedIndex = newStack.findIndex((stack) => stack.component)
// Clear the oldest cached component if there are too many cached components
const lastCachedIndex = newStack.findIndex((stack) => stack.element)
// Clear the oldest cached element if there are too many cached elements
if (newStack.length - lastCachedIndex > maxStackSize) {
newStack[lastCachedIndex].component = null
newStack[lastCachedIndex].element = null
}
return { newStack, newItem }
}

1
src/assets/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -13,7 +13,7 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
<>
<div className="text-xl font-semibold">Jumble</div>
<div className="text-muted-foreground">
A beautiful nostr client focused on browsing relay feeds
A user-friendly Nostr client for exploring relay feeds
</div>
<div>
Made by <Username userId={CODY_PUBKEY} className="inline-block text-primary" showAt />

View File

@@ -1,11 +1,12 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { TAccountPointer, TSignerType } from '@/types'
import { Loader } from 'lucide-react'
import { TAccountPointer } from '@/types'
import { Loader, Trash2 } from 'lucide-react'
import { useState } from 'react'
import SignerTypeBadge from '../SignerTypeBadge'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
@@ -16,7 +17,7 @@ export default function AccountList({
className?: string
afterSwitch: () => void
}) {
const { accounts, account, switchAccount } = useNostr()
const { accounts, account, switchAccount, removeAccount } = useNostr()
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
return (
@@ -37,17 +38,30 @@ export default function AccountList({
}}
>
<div className="flex justify-between items-center p-2">
<div className="flex items-center gap-2 relative">
<div className="flex-1 flex items-center gap-2 relative">
<SimpleUserAvatar userId={act.pubkey} />
<div>
<SimpleUsername userId={act.pubkey} className="font-semibold" />
<div className="flex-1 w-0">
<SimpleUsername userId={act.pubkey} className="font-semibold truncate" />
<div className="text-sm rounded-full bg-muted px-2 w-fit">
{formatPubkey(act.pubkey)}
</div>
</div>
</div>
<div className="flex gap-2 items-center">
<SignerTypeBadge signerType={act.signerType} />
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<SignerTypeBadge signerType={act.signerType} />
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
removeAccount(act)
}}
>
<Trash2 />
</Button>
</div>
</div>
{switchingAccount && isSameAccount(act, switchingAccount) && (
@@ -60,15 +74,3 @@ export default function AccountList({
</div>
)
}
function SignerTypeBadge({ signerType }: { signerType: TSignerType }) {
if (signerType === 'nip-07') {
return <Badge className=" bg-green-400 hover:bg-green-400/80">NIP-07</Badge>
} else if (signerType === 'bunker') {
return <Badge className=" bg-blue-400 hover:bg-blue-400/80">Bunker</Badge>
} else if (signerType === 'ncryptsec') {
return <Badge>NCRYPTSEC</Badge>
} else {
return <Badge className=" bg-orange-400 hover:bg-orange-400/80">NSEC</Badge>
}
}

View File

@@ -1,59 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, RefreshCcw } from 'lucide-react'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function GenerateNewAccount({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [nsec, setNsec] = useState(generateNsec())
const [copied, setCopied] = useState(false)
const handleLogin = () => {
nsecLogin(nsec).then(() => onLoginSuccess())
}
return (
<>
<div className="text-orange-400">
{t(
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.'
)}
</div>
<div className="flex gap-2">
<Input value={nsec} />
<Button variant="secondary" onClick={() => setNsec(generateNsec())}>
<RefreshCcw />
</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
<Button onClick={handleLogin}>{t('Login')}</Button>
<Button variant="secondary" onClick={back}>
{t('Back')}
</Button>
</>
)
}
function generateNsec() {
const sk = generateSecretKey()
return nsecEncode(sk)
}

View File

@@ -0,0 +1,270 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, Loader, ScanQrCode } from 'lucide-react'
import { generateSecretKey, getPublicKey } from 'nostr-tools'
import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46'
import QrScanner from 'qr-scanner'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import QrCode from '../QrCode'
export default function NostrConnectLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { nostrConnectionLogin, bunkerLogin } = useNostr()
const [pending, setPending] = useState(false)
const [bunkerInput, setBunkerInput] = useState('')
const [copied, setCopied] = useState(false)
const [errMsg, setErrMsg] = useState<string | null>(null)
const [nostrConnectionErrMsg, setNostrConnectionErrMsg] = useState<string | null>(null)
const qrContainerRef = useRef<HTMLDivElement>(null)
const [qrCodeSize, setQrCodeSize] = useState(100)
const [isScanning, setIsScanning] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const qrScannerRef = useRef<QrScanner | null>(null)
const qrScannerCheckTimerRef = useRef<NodeJS.Timeout | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBunkerInput(e.target.value)
if (errMsg) setErrMsg(null)
}
const handleLogin = (bunker: string = bunkerInput) => {
const _bunker = bunker.trim()
if (_bunker.trim() === '') return
setPending(true)
bunkerLogin(_bunker)
.then(() => onLoginSuccess())
.catch((err) => setErrMsg(err.message || 'Login failed'))
.finally(() => setPending(false))
}
const [loginDetails] = useState(() => {
const newPrivKey = generateSecretKey()
const newMeta: NostrConnectParams = {
clientPubkey: getPublicKey(newPrivKey),
relays: DEFAULT_NOSTRCONNECT_RELAY,
secret: Math.random().toString(36).substring(7),
name: document.location.host,
url: document.location.origin
}
const newConnectionString = createNostrConnectURI(newMeta)
return {
privKey: newPrivKey,
connectionString: newConnectionString
}
})
useLayoutEffect(() => {
const calculateQrSize = () => {
if (qrContainerRef.current) {
const containerWidth = qrContainerRef.current.offsetWidth
const desiredSizeBasedOnWidth = Math.min(containerWidth - 8, containerWidth * 0.9)
const newSize = Math.max(100, Math.min(desiredSizeBasedOnWidth, 360))
setQrCodeSize(newSize)
}
}
calculateQrSize()
const resizeObserver = new ResizeObserver(calculateQrSize)
if (qrContainerRef.current) {
resizeObserver.observe(qrContainerRef.current)
}
return () => {
if (qrContainerRef.current) {
resizeObserver.unobserve(qrContainerRef.current)
}
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
if (!loginDetails.privKey || !loginDetails.connectionString) return
setNostrConnectionErrMsg(null)
nostrConnectionLogin(loginDetails.privKey, loginDetails.connectionString)
.then(() => onLoginSuccess())
.catch((err) => {
console.error('NostrConnectionLogin Error:', err)
setNostrConnectionErrMsg(
err.message ? `${err.message}. Please reload.` : 'Connection failed. Please reload.'
)
})
}, [loginDetails, nostrConnectionLogin, onLoginSuccess])
const copyConnectionString = async () => {
if (!loginDetails.connectionString) return
navigator.clipboard.writeText(loginDetails.connectionString)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const startQrScan = async () => {
try {
setIsScanning(true)
setErrMsg(null)
// Wait for next render cycle to ensure video element is in DOM
await new Promise((resolve) => setTimeout(resolve, 100))
if (!videoRef.current) {
throw new Error('Video element not found')
}
const hasCamera = await QrScanner.hasCamera()
if (!hasCamera) {
throw new Error('No camera found')
}
const qrScanner = new QrScanner(
videoRef.current,
(result) => {
setBunkerInput(result.data)
stopQrScan()
handleLogin(result.data)
},
{
highlightScanRegion: true,
highlightCodeOutline: true,
preferredCamera: 'environment'
}
)
qrScannerRef.current = qrScanner
await qrScanner.start()
// Check video feed after a delay
qrScannerCheckTimerRef.current = setTimeout(() => {
if (
videoRef.current &&
(videoRef.current.videoWidth === 0 || videoRef.current.videoHeight === 0)
) {
setErrMsg('Camera feed not available')
}
}, 1000)
} catch (error) {
setErrMsg(
`Failed to start camera: ${error instanceof Error ? error.message : 'Unknown error'}. Please check permissions.`
)
setIsScanning(false)
if (qrScannerCheckTimerRef.current) {
clearTimeout(qrScannerCheckTimerRef.current)
qrScannerCheckTimerRef.current = null
}
}
}
const stopQrScan = () => {
if (qrScannerRef.current) {
qrScannerRef.current.stop()
qrScannerRef.current.destroy()
qrScannerRef.current = null
}
setIsScanning(false)
if (qrScannerCheckTimerRef.current) {
clearTimeout(qrScannerCheckTimerRef.current)
qrScannerCheckTimerRef.current = null
}
}
useEffect(() => {
return () => {
stopQrScan()
}
}, [])
return (
<div className="relative flex flex-col gap-4">
<div ref={qrContainerRef} className="flex flex-col items-center w-full space-y-3 mb-3">
<a href={loginDetails.connectionString} aria-label="Open with Nostr signer app">
<QrCode size={qrCodeSize} value={loginDetails.connectionString} />
</a>
{nostrConnectionErrMsg && (
<div className="text-xs text-destructive text-center pt-1">{nostrConnectionErrMsg}</div>
)}
</div>
<div className="flex justify-center w-full mb-3">
<div
className="flex items-center gap-2 text-sm text-muted-foreground bg-muted px-3 py-2 rounded-full cursor-pointer transition-all hover:bg-muted/80"
style={{
width: qrCodeSize > 0 ? `${Math.max(150, Math.min(qrCodeSize, 320))}px` : 'auto'
}}
onClick={copyConnectionString}
role="button"
tabIndex={0}
>
<div className="flex-grow min-w-0 truncate select-none">
{loginDetails.connectionString}
</div>
<div className="flex-shrink-0">{copied ? <Check size={14} /> : <Copy size={14} />}</div>
</div>
</div>
<div className="flex items-center w-full my-4">
<div className="flex-grow border-t border-border/40"></div>
<span className="px-3 text-xs text-muted-foreground">OR</span>
<div className="flex-grow border-t border-border/40"></div>
</div>
<div className="w-full space-y-1">
<div className="flex items-start space-x-2">
<div className="flex-1 relative">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive pr-10' : 'pr-10'}
/>
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0"
onClick={startQrScan}
disabled={pending}
>
<ScanQrCode />
</Button>
</div>
<Button onClick={() => handleLogin()} disabled={pending}>
<Loader className={pending ? 'animate-spin mr-2' : 'hidden'} />
{t('Login')}
</Button>
</div>
{errMsg && <div className="text-xs text-destructive pl-3 pt-1">{errMsg}</div>}
</div>
<Button variant="secondary" onClick={back} className="w-full">
{t('Back')}
</Button>
<div className={cn('w-full h-full flex justify-center', isScanning ? '' : 'hidden')}>
<video
ref={videoRef}
className="absolute inset-0 w-full h-full bg-background"
autoPlay
playsInline
muted
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={stopQrScan}
>
Cancel
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function NpubLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { npubLogin } = useNostr()
const [pending, setPending] = useState(false)
const [npubInput, setNpubInput] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNpubInput(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (npubInput === '') return
setPending(true)
npubLogin(npubInput)
.then(() => onLoginSuccess())
.catch((err) => setErrMsg(err.message))
.finally(() => setPending(false))
}
return (
<>
<div className="space-y-1">
<Input
placeholder="npub..."
value={npubInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div>
<Button onClick={handleLogin} disabled={pending}>
<Loader className={pending ? 'animate-spin' : 'hidden'} />
{t('Login')}
</Button>
<Button variant="secondary" onClick={back}>
{t('Back')}
</Button>
</>
)
}

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useNostr } from '@/providers/NostrProvider'
import { useState } from 'react'
@@ -31,19 +32,19 @@ export default function PrivateKeyLogin({
function NsecLogin({ back, onLoginSuccess }: { back: () => void; onLoginSuccess: () => void }) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [nsec, setNsec] = useState('')
const [nsecOrHex, setNsecOrHex] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const [password, setPassword] = useState('')
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNsec(e.target.value)
setNsecOrHex(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (nsec === '') return
if (nsecOrHex === '') return
nsecLogin(nsec, password)
nsecLogin(nsecOrHex, password)
.then(() => onLoginSuccess())
.catch((err) => {
setErrMsg(err.message)
@@ -51,39 +52,49 @@ function NsecLogin({ back, onLoginSuccess }: { back: () => void; onLoginSuccess:
}
return (
<div className="space-y-4">
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
handleLogin()
}}
>
<div className="text-orange-400">
{t(
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.'
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.'
)}
</div>
<div className="space-y-1">
<div className="text-muted-foreground text-sm font-semibold">nsec</div>
<div className="grid gap-2">
<Label htmlFor="nsec-input">nsec or hex</Label>
<Input
id="nsec-input"
type="password"
placeholder="nsec1.."
value={nsec}
placeholder="nsec1.. or hex"
value={nsecOrHex}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
{errMsg && <div className="text-xs text-destructive">{errMsg}</div>}
</div>
<div className="space-y-1">
<div className="text-muted-foreground text-sm font-semibold">{t('password')}</div>
<div className="grid gap-2">
<Label htmlFor="password-input">{t('password')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('optional: encrypt nsec')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button className="w-full" onClick={handleLogin}>
{t('Login')}
</Button>
<Button className="w-full" variant="secondary" onClick={back}>
{t('Back')}
</Button>
</div>
<div className="flex gap-2">
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
{t('Back')}
</Button>
<Button className="flex-1" type="submit">
{t('Login')}
</Button>
</div>
</form>
)
}
@@ -115,28 +126,33 @@ function NcryptsecLogin({
}
return (
<div className="space-y-4">
<div className="text-orange-400">
{t(
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.'
)}
</div>
<div className="space-y-1">
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
handleLogin()
}}
>
<div className="grid gap-2">
<Label htmlFor="ncryptsec-input">ncryptsec</Label>
<Input
id="ncryptsec-input"
type="password"
placeholder="ncryptsec1.."
value={ncryptsec}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
{errMsg && <div className="text-xs text-destructive">{errMsg}</div>}
</div>
<Button className="w-full" onClick={handleLogin}>
{t('Login')}
</Button>
<Button className="w-full" variant="secondary" onClick={back}>
{t('Back')}
</Button>
</div>
<div className="flex gap-2">
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
{t('Back')}
</Button>
<Button className="flex-1" type="submit">
{t('Login')}
</Button>
</div>
</form>
)
}

View File

@@ -0,0 +1,227 @@
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, Download, RefreshCcw } from 'lucide-react'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import InfoCard from '../InfoCard'
type Step = 'generate' | 'password'
export default function Signup({
back,
onSignupSuccess
}: {
back: () => void
onSignupSuccess: () => void
}) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [step, setStep] = useState<Step>('generate')
const [nsec, setNsec] = useState(generateNsec())
const [checkedSaveKey, setCheckedSaveKey] = useState(false)
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [copied, setCopied] = useState(false)
const handleDownload = () => {
const blob = new Blob([nsec], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'nostr-private-key.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const handleSignup = async () => {
await nsecLogin(nsec, password || undefined, true)
onSignupSuccess()
}
const passwordsMatch = password === confirmPassword
const canSubmit = !password || passwordsMatch
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-2">
{(['generate', 'password'] as Step[]).map((s, index) => (
<div key={s} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${
step === s
? 'bg-primary text-primary-foreground'
: step === 'password' && s === 'generate'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{index + 1}
</div>
{index < 1 && <div className="w-12 h-0.5 bg-muted mx-1" />}
</div>
))}
</div>
)
if (step === 'generate') {
return (
<div className="space-y-6">
{renderStepIndicator()}
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">{t('Create Your Nostr Account')}</h3>
<p className="text-sm text-muted-foreground">
{t('Generate your unique private key. This is your digital identity.')}
</p>
</div>
<InfoCard
variant="alert"
title={t('Critical: Save Your Private Key')}
content={t(
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.'
)}
/>
<div className="space-y-1">
<Label>{t('Your Private Key')}</Label>
<div className="flex gap-2">
<Input
value={nsec}
readOnly
className="font-mono text-sm"
onClick={(e) => e.currentTarget.select()}
/>
<Button
type="button"
variant="secondary"
size="icon"
onClick={() => setNsec(generateNsec())}
title={t('Generate new key')}
>
<RefreshCcw />
</Button>
</div>
</div>
<div className="w-full flex flex-col sm:flex-row gap-2 items-stretch">
<Button onClick={handleDownload} className="w-full">
<Download />
{t('Download Backup File')}
</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
variant="secondary"
className="w-full"
>
{copied ? <Check /> : <Copy />}
{copied ? t('Copied to Clipboard') : t('Copy to Clipboard')}
</Button>
</div>
<div className="flex items-center gap-2 ml-2">
<Checkbox
id="acknowledge-checkbox"
checked={checkedSaveKey}
onCheckedChange={(c) => setCheckedSaveKey(!!c)}
/>
<Label htmlFor="acknowledge-checkbox" className="cursor-pointer">
{t('I have safely backed up my private key')}
</Label>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={back} className="w-fit px-6">
{t('Back')}
</Button>
<Button onClick={() => setStep('password')} className="flex-1" disabled={!checkedSaveKey}>
{t('Continue')}
</Button>
</div>
</div>
)
}
// step === 'password'
return (
<div className="space-y-6">
{renderStepIndicator()}
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">{t('Secure Your Account')}</h3>
<p className="text-sm text-muted-foreground">
{t('Add an extra layer of protection with a password')}
</p>
</div>
<InfoCard
title={t('Password Protection (Recommended)')}
content={t(
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.'
)}
/>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="password-input">{t('Password (Optional)')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('Create a password (or skip)')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{password && (
<div className="space-y-1">
<Label htmlFor="confirm-password-input">{t('Confirm Password')}</Label>
<Input
id="confirm-password-input"
type="password"
placeholder={t('Enter your password again')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-500">{t('Passwords do not match')}</p>
)}
</div>
)}
</div>
<div className="w-full flex gap-2">
<Button
variant="secondary"
onClick={() => {
setStep('generate')
setPassword('')
setConfirmPassword('')
}}
className="w-fit px-6"
>
{t('Back')}
</Button>
<Button onClick={handleSignup} className="flex-1" disabled={!canSubmit}>
{t('Complete Signup')}
</Button>
</div>
</div>
)
}
function generateNsec() {
const sk = generateSecretKey()
return nsecEncode(sk)
}

View File

@@ -1,16 +1,16 @@
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { isDevEnv } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { NstartModal } from 'nstart-modal'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccountList from '../AccountList'
import BunkerLogin from './BunkerLogin'
import GenerateNewAccount from './GenerateNewAccount'
import NostrConnectLogin from './NostrConnectionLogin'
import NpubLogin from './NpubLogin'
import PrivateKeyLogin from './PrivateKeyLogin'
import Signup from './Signup'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | null
type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | 'signup' | null
export default function AccountManager({ close }: { close?: () => void }) {
const [page, setPage] = useState<TAccountManagerPage>(null)
@@ -20,9 +20,11 @@ export default function AccountManager({ close }: { close?: () => void }) {
{page === 'nsec' ? (
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'bunker' ? (
<BunkerLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'generate' ? (
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'npub' ? (
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'signup' ? (
<Signup back={() => setPage(null)} onSignupSuccess={() => close?.()} />
) : (
<AccountManagerNav setPage={setPage} close={close} />
)}
@@ -38,8 +40,7 @@ function AccountManagerNav({
close?: () => void
}) {
const { t } = useTranslation()
const { themeSetting } = useTheme()
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
const { nip07Login, accounts } = useNostr()
return (
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
@@ -59,6 +60,11 @@ function AccountManagerNav({
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
{t('Login with Private Key')}
</Button>
{isDevEnv() && (
<Button variant="secondary" onClick={() => setPage('npub')} className="w-full">
Login with Public key (for development)
</Button>
)}
</div>
</div>
<Separator />
@@ -66,37 +72,8 @@ function AccountManagerNav({
<div className="text-center text-muted-foreground text-sm font-semibold">
{t("Don't have an account yet?")}
</div>
<Button
onClick={() => {
const wizard = new NstartModal({
baseUrl: 'https://start.njump.me',
an: 'Jumble',
am: themeSetting,
onComplete: ({ nostrLogin }) => {
if (!nostrLogin) return
if (nostrLogin.startsWith('bunker://')) {
bunkerLogin(nostrLogin)
} else if (nostrLogin.startsWith('ncryptsec')) {
ncryptsecLogin(nostrLogin)
} else if (nostrLogin.startsWith('nsec')) {
nsecLogin(nostrLogin)
}
}
})
close?.()
wizard.open()
}}
className="w-full mt-4"
>
{t('Sign up')}
</Button>
<Button
variant="link"
onClick={() => setPage('generate')}
className="w-full text-muted-foreground py-0 h-fit mt-1"
>
{t('or simply generate a private key')}
<Button onClick={() => setPage('signup')} className="w-full mt-4">
{t('Create New Account')}
</Button>
</div>
{accounts.length > 0 && (

View File

@@ -0,0 +1,189 @@
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import mediaManager from '@/services/media-manager.service'
import { Minimize2, Pause, Play, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import ExternalLink from '../ExternalLink'
interface AudioPlayerProps {
src: string
autoPlay?: boolean
startTime?: number
isMinimized?: boolean
className?: string
}
export default function AudioPlayer({
src,
autoPlay = false,
startTime,
isMinimized = false,
className
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [error, setError] = useState(false)
const seekTimeoutRef = useRef<NodeJS.Timeout>()
const isSeeking = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
if (startTime) {
setCurrentTime(startTime)
audio.currentTime = startTime
}
if (autoPlay) {
togglePlay()
}
const updateTime = () => {
if (!isSeeking.current) {
setCurrentTime(audio.currentTime)
}
}
const updateDuration = () => setDuration(audio.duration)
const handleEnded = () => setIsPlaying(false)
const handlePause = () => setIsPlaying(false)
const handlePlay = () => setIsPlaying(true)
audio.addEventListener('timeupdate', updateTime)
audio.addEventListener('loadedmetadata', updateDuration)
audio.addEventListener('ended', handleEnded)
audio.addEventListener('pause', handlePause)
audio.addEventListener('play', handlePlay)
return () => {
audio.removeEventListener('timeupdate', updateTime)
audio.removeEventListener('loadedmetadata', updateDuration)
audio.removeEventListener('ended', handleEnded)
audio.removeEventListener('pause', handlePause)
audio.removeEventListener('play', handlePlay)
}
}, [])
useEffect(() => {
const audio = audioRef.current
const container = containerRef.current
if (!audio || !container) return
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) {
audio.pause()
}
},
{ threshold: 1 }
)
observer.observe(container)
return () => {
observer.unobserve(container)
}
}, [])
const togglePlay = () => {
const audio = audioRef.current
if (!audio) return
if (isPlaying) {
audio.pause()
setIsPlaying(false)
} else {
audio.play()
setIsPlaying(true)
mediaManager.play(audio)
}
}
const handleSeek = (value: number[]) => {
const audio = audioRef.current
if (!audio) return
isSeeking.current = true
setCurrentTime(value[0])
if (seekTimeoutRef.current) {
clearTimeout(seekTimeoutRef.current)
}
seekTimeoutRef.current = setTimeout(() => {
audio.currentTime = value[0]
isSeeking.current = false
}, 300)
}
if (error) {
return <ExternalLink url={src} />
}
return (
<div
ref={containerRef}
className={cn(
'flex items-center gap-3 py-2 px-2 border rounded-full max-w-md bg-background',
className
)}
onClick={(e) => e.stopPropagation()}
>
<audio ref={audioRef} src={src} preload="metadata" onError={() => setError(false)} />
{/* Play/Pause Button */}
<Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}>
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
</Button>
{/* Progress Section */}
<div className="flex-1 relative">
<Slider
value={[currentTime]}
max={duration || 100}
step={1}
onValueChange={handleSeek}
hideThumb
enableHoverAnimation
/>
</div>
<div className="text-sm font-mono text-muted-foreground">
{formatTime(Math.max(duration - currentTime, 0))}
</div>
{isMinimized ? (
<Button
variant="ghost"
size="icon"
className="rounded-full shrink-0 text-muted-foreground"
onClick={() => mediaManager.stopAudioBackground()}
>
<X />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="rounded-full shrink-0 text-muted-foreground"
onClick={() => mediaManager.playAudioBackground(src, audioRef.current?.currentTime || 0)}
>
<Minimize2 />
</Button>
)}
</div>
)
}
const formatTime = (time: number) => {
if (time === Infinity || isNaN(time)) {
return '-:--'
}
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

View File

@@ -1,22 +0,0 @@
import { Button } from '@/components/ui/button'
import { useSecondaryPage } from '@/PageManager'
import { ChevronLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function BackButton({ children }: { children?: React.ReactNode }) {
const { t } = useTranslation()
const { pop } = useSecondaryPage()
return (
<Button
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
variant="ghost"
size="titlebar-icon"
title={t('back')}
onClick={() => pop()}
>
<ChevronLeft />
<div className="truncate text-lg font-semibold">{children}</div>
</Button>
)
}

View File

@@ -0,0 +1,46 @@
import mediaManager from '@/services/media-manager.service'
import { useEffect, useState } from 'react'
import AudioPlayer from '../AudioPlayer'
export default function BackgroundAudio({ className }: { className?: string }) {
const [backgroundAudioSrc, setBackgroundAudioSrc] = useState<string | null>(null)
const [backgroundAudio, setBackgroundAudio] = useState<React.ReactNode>(null)
useEffect(() => {
const handlePlayAudioBackground = (event: Event) => {
const { src, time } = (event as CustomEvent).detail
if (backgroundAudioSrc === src) return
setBackgroundAudio(
<FloatingAudioPlayer key={src + time} src={src} time={time} className={className} />
)
setBackgroundAudioSrc(src)
}
const handleStopAudioBackground = () => {
setBackgroundAudio(null)
}
mediaManager.addEventListener('playAudioBackground', handlePlayAudioBackground)
mediaManager.addEventListener('stopAudioBackground', handleStopAudioBackground)
return () => {
mediaManager.removeEventListener('playAudioBackground', handlePlayAudioBackground)
mediaManager.removeEventListener('stopAudioBackground', handleStopAudioBackground)
}
}, [])
return backgroundAudio
}
function FloatingAudioPlayer({
src,
time,
className
}: {
src: string
time?: number
className?: string
}) {
return <AudioPlayer src={src} className={className} startTime={time} autoPlay isMinimized />
}

View File

@@ -0,0 +1,78 @@
import { useStuff } from '@/hooks/useStuff'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useBookmarks } from '@/providers/BookmarksProvider'
import { useNostr } from '@/providers/NostrProvider'
import { BookmarkIcon, Loader } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function BookmarkButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks()
const [updating, setUpdating] = useState(false)
const { event } = useStuff(stuff)
const isBookmarked = useMemo(() => {
if (!event) return false
const isReplaceable = isReplaceableEvent(event.kind)
const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id
return bookmarkListEvent?.tags.some((tag) =>
isReplaceable ? tag[0] === 'a' && tag[1] === eventKey : tag[0] === 'e' && tag[1] === eventKey
)
}, [bookmarkListEvent, event])
if (!accountPubkey) return null
const handleBookmark = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (isBookmarked || !event) return
setUpdating(true)
try {
await addBookmark(event)
} catch (error) {
toast.error(t('Bookmark failed') + ': ' + (error as Error).message)
} finally {
setUpdating(false)
}
})
}
const handleRemoveBookmark = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!isBookmarked || !event) return
setUpdating(true)
try {
await removeBookmark(event)
} catch (error) {
toast.error(t('Remove bookmark failed') + ': ' + (error as Error).message)
} finally {
setUpdating(false)
}
})
}
return (
<button
className={`flex items-center gap-1 ${
isBookmarked ? 'text-rose-400' : 'text-muted-foreground'
} enabled:hover:text-rose-400 px-3 h-full disabled:text-muted-foreground/40 disabled:cursor-default`}
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark}
disabled={!event || updating}
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
>
{updating ? (
<Loader className="animate-spin" />
) : (
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} />
)}
</button>
)
}

View File

@@ -0,0 +1,102 @@
import { useFetchEvent } from '@/hooks'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const SHOW_COUNT = 10
export default function BookmarkList() {
const { t } = useTranslation()
const { bookmarkListEvent } = useNostr()
const eventIds = useMemo(() => {
if (!bookmarkListEvent) return []
return (
bookmarkListEvent.tags
.map((tag) =>
tag[0] === 'e'
? generateBech32IdFromETag(tag)
: tag[0] === 'a'
? generateBech32IdFromATag(tag)
: null
)
.filter(Boolean) as (`nevent1${string}` | `naddr1${string}`)[]
).reverse()
}, [bookmarkListEvent])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = () => {
if (showCount < eventIds.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [showCount, eventIds])
if (eventIds.length === 0) {
return (
<div className="mt-2 text-sm text-center text-muted-foreground">
{t('no bookmarks found')}
</div>
)
}
return (
<div>
{eventIds.slice(0, showCount).map((eventId) => (
<BookmarkedNote key={eventId} eventId={eventId} />
))}
{showCount < eventIds.length ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton />
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-2">
{t('no more bookmarks')}
</div>
)}
</div>
)
}
function BookmarkedNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton className="border-b" />
}
if (!event) {
return null
}
return <NoteCard event={event} className="w-full" />
}

View File

@@ -1,44 +1,57 @@
import { generateImageByPubkey } from '@/lib/pubkey'
import { Skeleton } from '@/components/ui/skeleton'
import { LONG_PRESS_THRESHOLD } from '@/constants'
import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react'
import { useMemo } from 'react'
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'
import { Skeleton } from '../ui/skeleton'
import { useMemo, useRef, useState } from 'react'
import LoginDialog from '../LoginDialog'
import { SimpleUserAvatar } from '../UserAvatar'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function AccountButton() {
const { navigate, current } = usePrimaryPage()
const { navigate, current, display } = usePrimaryPage()
const { pubkey, profile } = useNostr()
const defaultAvatar = useMemo(
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
[profile]
)
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const active = useMemo(() => current === 'me' && display, [display, current])
const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handlePointerDown = () => {
pressTimerRef.current = setTimeout(() => {
setLoginDialogOpen(true)
pressTimerRef.current = null
}, LONG_PRESS_THRESHOLD)
}
const handlePointerUp = () => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current)
navigate('me')
pressTimerRef.current = null
}
}
return (
<BottomNavigationBarItem
onClick={() => {
navigate('me')
}}
active={current === 'me'}
>
{pubkey ? (
profile ? (
<Avatar className={cn('w-7 h-7', current === 'me' ? 'ring-primary ring-1' : '')}>
<AvatarImage src={profile.avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
<>
<BottomNavigationBarItem
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
active={active}
>
{pubkey ? (
profile ? (
<SimpleUserAvatar
userId={pubkey}
className={cn('size-6', active ? 'ring-primary ring-2' : '')}
/>
) : (
<Skeleton className={cn('size-6 rounded-full', active ? 'ring-primary ring-2' : '')} />
)
) : (
<Skeleton
className={cn('w-7 h-7 rounded-full', current === 'me' ? 'ring-primary ring-1' : '')}
/>
)
) : (
<UserRound />
)}
</BottomNavigationBarItem>
<UserRound />
)}
</BottomNavigationBarItem>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
</>
)
}

View File

@@ -1,15 +1,19 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
import { MouseEventHandler } from 'react'
export default function BottomNavigationBarItem({
children,
active = false,
onClick
onClick,
onPointerDown,
onPointerUp
}: {
children: React.ReactNode
active?: boolean
onClick: MouseEventHandler
onClick?: MouseEventHandler
onPointerDown?: MouseEventHandler
onPointerUp?: MouseEventHandler
}) {
return (
<Button
@@ -19,6 +23,8 @@ export default function BottomNavigationBarItem({
)}
variant="ghost"
onClick={onClick}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
>
{children}
</Button>

View File

@@ -3,10 +3,13 @@ import { Compass } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function ExploreButton() {
const { navigate, current } = usePrimaryPage()
const { navigate, current, display } = usePrimaryPage()
return (
<BottomNavigationBarItem active={current === 'explore'} onClick={() => navigate('explore')}>
<BottomNavigationBarItem
active={current === 'explore' && display}
onClick={() => navigate('explore')}
>
<Compass />
</BottomNavigationBarItem>
)

View File

@@ -3,10 +3,13 @@ import { Home } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function HomeButton() {
const { navigate, current } = usePrimaryPage()
const { navigate, current, display } = usePrimaryPage()
return (
<BottomNavigationBarItem active={current === 'home'} onClick={() => navigate('home')}>
<BottomNavigationBarItem
active={current === 'home' && display}
onClick={() => navigate('home')}
>
<Home />
</BottomNavigationBarItem>
)

View File

@@ -1,21 +1,23 @@
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationProvider'
import { Bell } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function NotificationsButton() {
const { navigate, current } = usePrimaryPage()
const { checkLogin } = useNostr()
const { navigate, current, display } = usePrimaryPage()
const { hasNewNotification } = useNotification()
return (
<BottomNavigationBarItem
active={current === 'notifications'}
onClick={() => navigate('notifications')}
active={current === 'notifications' && display}
onClick={() => checkLogin(() => navigate('notifications'))}
>
<div className="relative">
<Bell />
{hasNewNotification && (
<div className="absolute -top-0.5 right-0.5 w-2 h-2 bg-primary rounded-full" />
<div className="absolute -top-0.5 right-0.5 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
)}
</div>
</BottomNavigationBarItem>

View File

@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'
import BackgroundAudio from '../BackgroundAudio'
import AccountButton from './AccountButton'
import ExploreButton from './ExploreButton'
import HomeButton from './HomeButton'
@@ -7,18 +8,18 @@ import NotificationsButton from './NotificationsButton'
export default function BottomNavigationBar() {
return (
<div
className={cn(
'fixed bottom-0 w-full z-40 bg-background/80 backdrop-blur-xl flex items-center justify-around [&_svg]:size-4 [&_svg]:shrink-0'
)}
className={cn('fixed bottom-0 w-full z-40 bg-background border-t')}
style={{
height: 'calc(3rem + env(safe-area-inset-bottom))',
paddingBottom: 'env(safe-area-inset-bottom)'
}}
>
<HomeButton />
<ExploreButton />
<NotificationsButton />
<AccountButton />
<BackgroundAudio className="rounded-none border-x-0 border-t-0 border-b bg-background" />
<div className="w-full flex justify-around items-center [&_svg]:size-4 [&_svg]:shrink-0">
<HomeButton />
<ExploreButton />
<NotificationsButton />
<AccountButton />
</div>
</div>
)
}

View File

@@ -0,0 +1,246 @@
import { Button, ButtonProps } from '@/components/ui/button'
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerOverlay, DrawerTrigger } from '@/components/ui/drawer'
import { Separator } from '@/components/ui/separator'
import { ExtendedKind } from '@/constants'
import { getReplaceableEventIdentifier, getNoteBech32Id } from '@/lib/event'
import { toChachiChat } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import clientService from '@/services/client.service'
import { ExternalLink } from 'lucide-react'
import { Event, kinds, nip19 } from 'nostr-tools'
import { Dispatch, SetStateAction, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const clients: Record<string, { name: string; getUrl: (id: string) => string }> = {
nosta: {
name: 'Nosta',
getUrl: (id: string) => `https://nosta.me/${id}`
},
snort: {
name: 'Snort',
getUrl: (id: string) => `https://snort.social/${id}`
},
olas: {
name: 'Olas',
getUrl: (id: string) => `https://olas.app/e/${id}`
},
primal: {
name: 'Primal',
getUrl: (id: string) => `https://primal.net/e/${id}`
},
nostrudel: {
name: 'Nostrudel',
getUrl: (id: string) => `https://nostrudel.ninja/l/${id}`
},
nostter: {
name: 'Nostter',
getUrl: (id: string) => `https://nostter.app/${id}`
},
coracle: {
name: 'Coracle',
getUrl: (id: string) => `https://coracle.social/${id}`
},
iris: {
name: 'Iris',
getUrl: (id: string) => `https://iris.to/${id}`
},
lumilumi: {
name: 'Lumilumi',
getUrl: (id: string) => `https://lumilumi.app/${id}`
},
zapStream: {
name: 'zap.stream',
getUrl: (id: string) => `https://zap.stream/${id}`
},
yakihonne: {
name: 'YakiHonne',
getUrl: (id: string) => `https://yakihonne.com/${id}`
},
habla: {
name: 'Habla',
getUrl: (id: string) => `https://habla.news/a/${id}`
},
pareto: {
name: 'Pareto',
getUrl: (id: string) => `https://pareto.space/a/${id}`
},
njump: {
name: 'Njump',
getUrl: (id: string) => `https://njump.me/${id}`
}
}
export default function ClientSelect({
event,
originalNoteId,
...props
}: ButtonProps & {
event?: Event
originalNoteId?: string
}) {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const supportedClients = useMemo(() => {
let kind: number | undefined
if (event) {
kind = event.kind
} else if (originalNoteId) {
try {
const pointer = nip19.decode(originalNoteId)
if (pointer.type === 'naddr') {
kind = pointer.data.kind
}
} catch (error) {
console.error('Failed to decode NIP-19 pointer:', error)
return ['njump']
}
}
if (!kind) {
return ['njump']
}
switch (kind) {
case kinds.LongFormArticle:
case kinds.DraftLong:
return ['yakihonne', 'coracle', 'habla', 'lumilumi', 'pareto', 'njump']
case kinds.LiveEvent:
return ['zapStream', 'nostrudel', 'njump']
case kinds.Date:
case kinds.Time:
return ['coracle', 'njump']
case kinds.CommunityDefinition:
return ['coracle', 'snort', 'njump']
default:
return ['njump']
}
}, [event])
if (!originalNoteId && !event) {
return null
}
const content = (
<div className="space-y-2">
{event?.kind === ExtendedKind.GROUP_METADATA ? (
<RelayBasedGroupChatSelector
event={event}
originalNoteId={originalNoteId}
setOpen={setOpen}
/>
) : (
supportedClients.map((clientId) => {
const client = clients[clientId]
if (!client) return null
return (
<ClientSelectItem
key={clientId}
onClick={() => setOpen(false)}
href={client.getUrl(originalNoteId ?? getNoteBech32Id(event!))}
name={client.name}
/>
)
})
)}
<Separator />
<Button
variant="ghost"
className="w-full py-6 font-semibold"
onClick={() => {
navigator.clipboard.writeText(originalNoteId ?? getNoteBech32Id(event!))
setOpen(false)
}}
>
{t('Copy event ID')}
</Button>
</div>
)
const trigger = (
<Button variant="outline" {...props}>
<ExternalLink /> {t('Open in another client')}
</Button>
)
if (isSmallScreen) {
return (
<div onClick={(e) => e.stopPropagation()}>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerOverlay
onClick={(e) => {
e.stopPropagation()
setOpen(false)
}}
/>
<DrawerContent hideOverlay>{content}</DrawerContent>
</Drawer>
</div>
)
}
return (
<div onClick={(e) => e.stopPropagation()}>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="px-8" onOpenAutoFocus={(e) => e.preventDefault()}>
{content}
</DialogContent>
</Dialog>
</div>
)
}
function RelayBasedGroupChatSelector({
event,
originalNoteId,
setOpen
}: {
event: Event
setOpen: Dispatch<SetStateAction<boolean>>
originalNoteId?: string
}) {
const { relay, id } = useMemo(() => {
let relay: string | undefined
if (originalNoteId) {
const pointer = nip19.decode(originalNoteId)
if (pointer.type === 'naddr' && pointer.data.relays?.length) {
relay = pointer.data.relays[0]
}
}
if (!relay) {
relay = clientService.getEventHint(event.id)
}
return { relay, id: getReplaceableEventIdentifier(event) }
}, [event, originalNoteId])
return (
<ClientSelectItem
onClick={() => setOpen(false)}
href={toChachiChat(relay, id)}
name="Chachi Chat"
/>
)
}
function ClientSelectItem({
onClick,
href,
name
}: {
onClick: () => void
href: string
name: string
}) {
return (
<Button asChild variant="ghost" className="w-full py-6 font-semibold" onClick={onClick}>
<a href={href} target="_blank" rel="noopener noreferrer">
{name}
</a>
</Button>
)
}

View File

@@ -0,0 +1,17 @@
import { getUsingClient } from '@/lib/event'
import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function ClientTag({ event }: { event: NostrEvent }) {
const { t } = useTranslation()
const usingClient = useMemo(() => getUsingClient(event), [event])
if (!usingClient) return null
return (
<span className="text-sm text-muted-foreground shrink-0">
{t('via {{client}}', { client: usingClient })}
</span>
)
}

View File

@@ -0,0 +1,76 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function Collapsible({
alwaysExpand = false,
children,
className,
threshold = 1000,
collapsedHeight = 600,
...props
}: {
alwaysExpand?: boolean
threshold?: number
collapsedHeight?: number
} & React.HTMLProps<HTMLDivElement>) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
const [shouldCollapse, setShouldCollapse] = useState(false)
useEffect(() => {
if (alwaysExpand || shouldCollapse) return
const contentEl = containerRef.current
if (!contentEl) return
const checkHeight = () => {
const fullHeight = contentEl.scrollHeight
if (fullHeight > threshold) {
setShouldCollapse(true)
}
}
checkHeight()
const observer = new ResizeObserver(() => {
checkHeight()
})
observer.observe(contentEl)
return () => {
observer.disconnect()
}
}, [alwaysExpand, shouldCollapse])
return (
<div
className={cn('relative text-left overflow-hidden', className)}
ref={containerRef}
{...props}
style={{
maxHeight: !shouldCollapse || expanded ? 'none' : `${collapsedHeight}px`
}}
>
{children}
{shouldCollapse && !expanded && (
<div className="absolute bottom-0 h-40 w-full z-10 bg-gradient-to-b from-transparent to-background/90 flex items-end justify-center pb-4">
<div className="bg-background rounded-lg">
<Button
className="bg-foreground hover:bg-foreground/80"
onClick={(e) => {
e.stopPropagation()
setExpanded(!expanded)
}}
>
{t('Show more')}
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,146 +1,200 @@
import { URL_REGEX } from '@/constants'
import { isNsfwEvent, isPictureEvent } from '@/lib/event'
import { extractImageInfoFromTag } from '@/lib/tag'
import { isImage, isVideo } from '@/lib/url'
import { cn } from '@/lib/utils'
import { TImageInfo } from '@/types'
import { Event } from 'nostr-tools'
import { memo } from 'react'
import { useTranslatedEvent } from '@/hooks'
import {
embedded,
embeddedHashtagRenderer,
embeddedNormalUrlRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer,
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedHashtagParser,
EmbeddedLNInvoiceParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import mediaUpload from '@/services/media-upload.service'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo, useRef, useState } from 'react'
import {
EmbeddedHashtag,
EmbeddedLNInvoice,
EmbeddedMention,
EmbeddedNote,
embeddedWebsocketUrlRenderer
EmbeddedWebsocketUrl
} from '../Embedded'
import Emoji from '../Emoji'
import ExternalLink from '../ExternalLink'
import HighlightButton from '../HighlightButton'
import ImageGallery from '../ImageGallery'
import VideoPlayer from '../VideoPlayer'
import MediaPlayer from '../MediaPlayer'
import PostEditor from '../PostEditor'
import WebPreview from '../WebPreview'
import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
const Content = memo(
({
event,
className,
size = 'normal',
disableLightbox = false
}: {
event: Event
className?: string
size?: 'normal' | 'small'
disableLightbox?: boolean
}) => {
const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event)
const isNsfw = isNsfwEvent(event)
const nodes = embedded(content, [
embeddedNormalUrlRenderer,
embeddedWebsocketUrlRenderer,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer
export default function Content({
event,
content,
className,
mustLoadMedia,
enableHighlight = false
}: {
event?: Event
content?: string
className?: string
mustLoadMedia?: boolean
enableHighlight?: boolean
}) {
const contentRef = useRef<HTMLDivElement>(null)
const [showHighlightEditor, setShowHighlightEditor] = useState(false)
const [selectedText, setSelectedText] = useState('')
const translatedEvent = useTranslatedEvent(event?.id)
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
const _content = translatedEvent?.content ?? event?.content ?? content
if (!_content) return {}
const nodes = parseContent(_content, [
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedLNInvoiceParser,
EmbeddedWebsocketUrlParser,
EmbeddedHashtagParser,
EmbeddedEmojiParser
])
// Add images
if (images.length) {
nodes.push(
<ImageGallery
className={`${size === 'small' ? 'mt-1' : 'mt-2'}`}
key={`image-gallery-${event.id}`}
images={images}
isNsfw={isNsfw}
size={size}
disableLightbox={disableLightbox}
/>
)
}
// Add videos
if (videos.length) {
videos.forEach((src, index) => {
nodes.push(
<VideoPlayer
className={size === 'small' ? 'mt-1' : 'mt-2'}
key={`video-${index}-${src}`}
src={src}
isNsfw={isNsfw}
size={size}
/>
)
const imetaInfos = event ? getImetaInfosFromEvent(event) : []
const allImages = nodes
.map((node) => {
if (node.type === 'image') {
const imageInfo = imetaInfos.find((image) => image.url === node.data)
if (imageInfo) {
return imageInfo
}
const tag = mediaUpload.getImetaTagByUrl(node.data)
return tag
? getImetaInfoFromImetaTag(tag, event?.pubkey)
: { url: node.data, pubkey: event?.pubkey }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imetaInfos.find((image) => image.url === url)
return imageInfo ?? { url, pubkey: event?.pubkey }
})
}
return null
})
}
.filter(Boolean)
.flat() as TImetaInfo[]
// Add website preview
if (lastNonMediaUrl) {
nodes.push(
<WebPreview
className={size === 'small' ? 'mt-1' : 'mt-2'}
key={`web-preview-${event.id}`}
url={lastNonMediaUrl}
size={size}
/>
)
}
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
// Add embedded notes
if (embeddedNotes.length) {
embeddedNotes.forEach((note, index) => {
const id = note.split(':')[1]
nodes.push(
<EmbeddedNote
key={`embedded-event-${index}`}
noteId={id}
className={size === 'small' ? 'mt-1' : 'mt-2'}
/>
)
})
}
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return <div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>{nodes}</div>
return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content])
if (!nodes || nodes.length === 0) {
return null
}
)
Content.displayName = 'Content'
export default Content
function preprocess(event: Event) {
const content = event.content
const urls = content.match(URL_REGEX) || []
let lastNonMediaUrl: string | undefined
const handleHighlight = (text: string) => {
setSelectedText(text)
setShowHighlightEditor(true)
}
let c = content
const imageUrls: string[] = []
const videos: string[] = []
urls.forEach((url) => {
if (isImage(url)) {
c = c.replace(url, '').trim()
imageUrls.push(url)
} else if (isVideo(url)) {
c = c.replace(url, '').trim()
videos.push(url)
} else {
lastNonMediaUrl = url
}
})
const imageInfos = event.tags
.map((tag) => extractImageInfoFromTag(tag))
.filter(Boolean) as TImageInfo[]
const images = isPictureEvent(event)
? imageInfos
: imageUrls.map((url) => {
const imageInfo = imageInfos.find((info) => info.url === url)
return imageInfo ?? { url }
})
const embeddedNotes: string[] = []
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
;(c.match(embeddedNoteRegex) || []).forEach((note) => {
c = c.replace(note, '').trim()
embeddedNotes.push(note)
})
c = c.replace(/\n{3,}/g, '\n\n').trim()
return { content: c, images, videos, embeddedNotes, lastNonMediaUrl }
let imageIndex = 0
return (
<>
<div ref={contentRef} className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
const start = imageIndex
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
imageIndex = end
return (
<ImageGallery
className="mt-2"
key={index}
images={allImages}
start={start}
end={end}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'media') {
return (
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
)
}
if (node.type === 'url') {
return <ExternalLink url={node.data} key={index} />
}
if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} key={index} />
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
}
if (node.type === 'youtube') {
return (
<YoutubeEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'x-post') {
return (
<XEmbeddedPost
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
{enableHighlight && (
<HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
)}
{enableHighlight && (
<PostEditor
highlightedText={selectedText}
parentStuff={event}
open={showHighlightEditor}
setOpen={setShowHighlightEditor}
/>
)}
</>
)
}

View File

@@ -0,0 +1,22 @@
import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function CommunityDefinitionPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Community')}] <span className="italic pr-0.5">{metadata.name}</span>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
parseContent
} from '@/lib/content-parser'
import { cn } from '@/lib/utils'
import { TEmoji } from '@/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { EmbeddedMentionText } from '../Embedded'
import Emoji from '../Emoji'
export default function Content({
content,
className,
emojiInfos
}: {
content: string
className?: string
emojiInfos?: TEmoji[]
}) {
const { t } = useTranslation()
const nodes = useMemo(() => {
return parseContent(content, [
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedEmojiParser
])
}, [content])
return (
<span className={cn('pointer-events-none', className)}>
{nodes.map((node, index) => {
if (node.type === 'image' || node.type === 'images') {
return index > 0 ? ` [${t('Image')}]` : `[${t('Image')}]`
}
if (node.type === 'media') {
return index > 0 ? ` [${t('Media')}]` : `[${t('Media')}]`
}
if (node.type === 'event') {
return index > 0 ? ` [${t('Note')}]` : `[${t('Note')}]`
}
if (node.type === 'mention') {
return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos?.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji key={index} emoji={emoji} classNames={{ img: 'size-4' }} />
}
return node.data
})}
</span>
)
}

View File

@@ -0,0 +1,23 @@
import { getEmojiPackInfoFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function EmojiPackPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const { title, emojis } = useMemo(() => getEmojiPackInfoFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Emoji Pack')}] <span className="italic pr-0.5">{title}</span>
{emojis.length > 0 && <span>({emojis.length})</span>}
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { getFollowPackInfoFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function FollowPackPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const { title } = useMemo(() => getFollowPackInfoFromEvent(event), [event])
return (
<div className={cn('truncate', className)}>
[{t('Follow Pack')}] <span className="italic pr-0.5">{title}</span>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { getGroupMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function GroupMetadataPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Group')}] <span className="italic pr-0.5">{metadata.name}</span>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { useTranslatedEvent } from '@/hooks'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Content from './Content'
export default function HighlightPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const translatedEvent = useTranslatedEvent(event.id)
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Highlight')}]{' '}
<Content
content={translatedEvent?.content ?? event.content}
emojiInfos={emojiInfos}
className="italic pr-0.5"
/>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function LiveEventPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Live event')}] <span className="italic pr-0.5">{metadata.title}</span>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function LongFormArticlePreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Article')}] <span className="italic pr-0.5">{metadata.title}</span>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useTranslatedEvent } from '@/hooks'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Content from './Content'
export default function NormalContentPreview({
event,
className
}: {
event: Event
className?: string
}) {
const translatedEvent = useTranslatedEvent(event?.id)
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event])
return (
<Content
content={translatedEvent?.content ?? event.content}
className={className}
emojiInfos={emojiInfos}
/>
)
}

View File

@@ -0,0 +1,19 @@
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
export default function PictureNotePreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
return (
<div className={cn('pointer-events-none', className)}>
[{t('Image')}] <span className="italic pr-0.5">{event.content}</span>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useTranslatedEvent } from '@/hooks'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Content from './Content'
export default function PollPreview({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const translatedEvent = useTranslatedEvent(event.id)
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Poll')}]{' '}
<Content
content={translatedEvent?.content ?? event.content}
emojiInfos={emojiInfos}
className="italic pr-0.5"
/>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
export default function VideoNotePreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
return (
<div className={cn('pointer-events-none', className)}>
[{t('Media')}] <span className="italic pr-0.5">{event.content}</span>
</div>
)
}

View File

@@ -1,13 +1,22 @@
import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event'
import { ExtendedKind } from '@/constants'
import { isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
embedded,
embeddedNostrNpubTextRenderer,
embeddedNostrProfileTextRenderer
} from '../Embedded'
import CommunityDefinitionPreview from './CommunityDefinitionPreview'
import EmojiPackPreview from './EmojiPackPreview'
import FollowPackPreview from './FollowPackPreview'
import GroupMetadataPreview from './GroupMetadataPreview'
import HighlightPreview from './HighlightPreview'
import LiveEventPreview from './LiveEventPreview'
import LongFormArticlePreview from './LongFormArticlePreview'
import NormalContentPreview from './NormalContentPreview'
import PictureNotePreview from './PictureNotePreview'
import PollPreview from './PollPreview'
import VideoNotePreview from './VideoNotePreview'
export default function ContentPreview({
event,
@@ -17,24 +26,89 @@ export default function ContentPreview({
className?: string
}) {
const { t } = useTranslation()
const content = useMemo(() => {
if (!event) return `[${t('Not found the note')}]`
const { contentWithoutEmbeddedNotes, embeddedNotes } = extractEmbeddedNotesFromContent(
event.content
)
const { contentWithoutImages, images } = extractImagesFromContent(contentWithoutEmbeddedNotes)
const contents = [contentWithoutImages]
if (images?.length) {
contents.push(`[${t('image')}]`)
}
if (embeddedNotes.length) {
contents.push(`[${t('note')}]`)
}
return embedded(contents.join(' '), [
embeddedNostrProfileTextRenderer,
embeddedNostrNpubTextRenderer
])
}, [event])
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const isMuted = useMemo(
() => (event ? mutePubkeySet.has(event.pubkey) : false),
[mutePubkeySet, event]
)
const isMentioningMuted = useMemo(
() =>
hideContentMentioningMutedUsers && event
? isMentioningMutedUsers(event, mutePubkeySet)
: false,
[event, mutePubkeySet]
)
return <div className={cn('pointer-events-none', className)}>{content}</div>
if (!event) {
return <div className={cn('pointer-events-none', className)}>{`[${t('Note not found')}]`}</div>
}
if (isMuted) {
return (
<div className={cn('pointer-events-none', className)}>[{t('This user has been muted')}]</div>
)
}
if (isMentioningMuted) {
return (
<div className={cn('pointer-events-none', className)}>
[{t('This note mentions a user you muted')}]
</div>
)
}
if (
[
kinds.ShortTextNote,
ExtendedKind.COMMENT,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.RELAY_REVIEW
].includes(event.kind)
) {
return <NormalContentPreview event={event} className={className} />
}
if (event.kind === kinds.Highlights) {
return <HighlightPreview event={event} className={className} />
}
if (event.kind === ExtendedKind.POLL) {
return <PollPreview event={event} className={className} />
}
if (event.kind === kinds.LongFormArticle) {
return <LongFormArticlePreview event={event} className={className} />
}
if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
return <VideoNotePreview event={event} className={className} />
}
if (event.kind === ExtendedKind.PICTURE) {
return <PictureNotePreview event={event} className={className} />
}
if (event.kind === ExtendedKind.GROUP_METADATA) {
return <GroupMetadataPreview event={event} className={className} />
}
if (event.kind === kinds.CommunityDefinition) {
return <CommunityDefinitionPreview event={event} className={className} />
}
if (event.kind === kinds.LiveEvent) {
return <LiveEventPreview event={event} className={className} />
}
if (event.kind === kinds.Emojisets) {
return <EmojiPackPreview event={event} className={className} />
}
if (event.kind === ExtendedKind.FOLLOW_PACK) {
return <FollowPackPreview event={event} className={className} />
}
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
}

View File

@@ -0,0 +1,31 @@
import { toWallet } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import storage from '@/services/local-storage.service'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function CreateWalletGuideToast() {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { profile } = useNostr()
useEffect(() => {
if (
profile &&
!profile.lightningAddress &&
!storage.hasShownCreateWalletGuideToast(profile.pubkey)
) {
toast(t('Set up your wallet to send and receive sats!'), {
action: {
label: t('Set up'),
onClick: () => push(toWallet())
}
})
storage.markCreateWalletGuideToastAsShown(profile.pubkey)
}
}, [profile])
return null
}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import Image from '../Image'
import OpenSatsLogo from './open-sats-logo.svg'
export default function PlatinumSponsors() {
const { t } = useTranslation()
return (
<div className="space-y-2">
<div className="font-semibold text-center">{t('Platinum Sponsors')}</div>
<div className="flex flex-col gap-2 items-center">
<div
className="flex items-center gap-4 cursor-pointer"
onClick={() => window.open('https://opensats.org/', '_blank')}
>
<Image
image={{
url: OpenSatsLogo
}}
className="h-11"
/>
<div className="text-2xl font-semibold">OpenSats</div>
</div>
</div>
</div>
)
}

View File

@@ -1,9 +1,9 @@
import { formatAmount } from '@/lib/lightning'
import lightning, { TRecentSupporter } from '@/services/lightning.service'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import { useTranslation } from 'react-i18next'
export default function RecentSupporters() {
const { t } = useTranslation()
@@ -32,7 +32,9 @@ export default function RecentSupporters() {
<UserAvatar userId={item.pubkey} />
<div className="flex-1 w-0">
<Username className="font-semibold w-fit" userId={item.pubkey} />
<div className="text-xs text-muted-foreground line-clamp-3">{item.comment}</div>
<div className="text-xs text-muted-foreground line-clamp-3 select-text">
{item.comment}
</div>
</div>
</div>
<div className="font-semibold text-yellow-400 shrink-0">

View File

@@ -1,9 +1,10 @@
import { Button } from '@/components/ui/button'
import { CODY_PUBKEY } from '@/constants'
import { JUMBLE_PUBKEY } from '@/constants'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ZapDialog from '../ZapDialog'
import PlatinumSponsors from './PlatinumSponsors'
import RecentSupporters from './RecentSupporters'
export default function Donation({ className }: { className?: string }) {
@@ -39,11 +40,12 @@ export default function Donation({ className }: { className?: string }) {
)
})}
</div>
<PlatinumSponsors />
<RecentSupporters />
<ZapDialog
open={open}
setOpen={setOpen}
pubkey={CODY_PUBKEY}
pubkey={JUMBLE_PUBKEY}
defaultAmount={donationAmount}
/>
</div>

View File

@@ -0,0 +1 @@
<svg viewBox="344.564 330.278 111.737 91.218" width="53.87" height="43.61" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><radialGradient xlink:href="#logo_svg__a" id="logo_svg__b" cx="31.833" cy="29.662" fx="31.833" fy="29.662" r="42.553" gradientTransform="matrix(2 0 0 1.99696 -74.45 12.982)" gradientUnits="userSpaceOnUse"></radialGradient><radialGradient xlink:href="#logo_svg__a" id="logo_svg__c" cx="31.833" cy="29.662" fx="31.833" fy="29.662" r="42.553" gradientTransform="matrix(2 0 0 1.99696 -74.45 12.982)" gradientUnits="userSpaceOnUse"></radialGradient><linearGradient id="logo_svg__a"><stop style="stop-color:#ffb200;stop-opacity:1" offset="0"></stop><stop style="stop-color:#ff6b01;stop-opacity:1" offset="0.493"></stop></linearGradient></defs><path style="font-variation-settings:'wght' 700;opacity:1;fill:url(#logo_svg__b);fill-opacity:1;stroke-width:10.5833;stroke-linecap:round;stroke-linejoin:round" d="M32.574 39.319v3.81h16.11v-3.81z" transform="translate(324.22 304.883) scale(2.39915)"></path><path style="font-variation-settings:'wght' 700;fill:url(#logo_svg__c);fill-opacity:1;stroke-width:10.5833;stroke-linecap:round;stroke-linejoin:round" d="M14.85 16.062v4.551l8.944 5.681v.137l-8.945 5.68v4.551l13.029-8.555v-3.49Z" transform="translate(324.22 304.883) scale(2.39915)"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,25 @@
import { Button } from '@/components/ui/button'
import { DrawerClose } from '@/components/ui/drawer'
import { cn } from '@/lib/utils'
export default function DrawerMenuItem({
children,
className,
onClick
}: {
children: React.ReactNode
className?: string
onClick?: (e: React.MouseEvent) => void
}) {
return (
<DrawerClose className="w-full">
<Button
onClick={onClick}
className={cn('w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5', className)}
variant="ghost"
>
{children}
</Button>
</DrawerClose>
)
}

View File

@@ -1,22 +1,14 @@
import { toNoteList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { TEmbeddedRenderer } from './types'
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
return (
<SecondaryPageLink
className="text-highlight hover:underline"
to={toNoteList({ hashtag })}
className="text-primary hover:underline"
to={toNoteList({ hashtag: hashtag.replace('#', '') })}
onClick={(e) => e.stopPropagation()}
>
#{hashtag}
{hashtag}
</SecondaryPageLink>
)
}
export const embeddedHashtagRenderer: TEmbeddedRenderer = {
regex: /#([\p{L}\p{N}\p{M}]+)/gu,
render: (hashtag: string, index: number) => {
return <EmbeddedHashtag key={`hashtag-${index}-${hashtag}`} hashtag={hashtag} />
}
}

View File

@@ -0,0 +1,64 @@
import { Button } from '@/components/ui/button'
import { formatAmount, getInvoiceDetails } from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import lightning from '@/services/lightning.service'
import { Loader, Zap } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; className?: string }) {
const { t } = useTranslation()
const { checkLogin, pubkey } = useNostr()
const [paying, setPaying] = useState(false)
const { amount, description } = useMemo(() => {
return getInvoiceDetails(invoice)
}, [invoice])
const handlePay = async () => {
try {
if (!pubkey) {
throw new Error('You need to be logged in to zap')
}
setPaying(true)
const invoiceResult = await lightning.payInvoice(invoice)
// user canceled
if (!invoiceResult) {
return
}
} catch (error) {
toast.error(t('Lightning payment failed') + ': ' + (error as Error).message)
} finally {
setPaying(false)
}
}
const handlePayClick = (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(() => handlePay())
}
return (
<div
className={cn('p-3 border rounded-lg cursor-default flex flex-col gap-3 max-w-sm', className)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-400" />
<div className="font-semibold text-sm">{t('Lightning Invoice')}</div>
</div>
{description && (
<div className="text-sm text-muted-foreground break-words">{description}</div>
)}
<div className="text-lg font-bold">
{formatAmount(amount)} {t('sats')}
</div>
<Button onClick={handlePayClick}>
{paying && <Loader className="w-4 h-4 animate-spin" />}
{t('Pay')}
</Button>
</div>
)
}

View File

@@ -1,61 +1,19 @@
import { cn } from '@/lib/utils'
import Username, { SimpleUsername } from '../Username'
import { TEmbeddedRenderer } from './types'
export function EmbeddedMention({ userId }: { userId: string }) {
export function EmbeddedMention({ userId, className }: { userId: string; className?: string }) {
return (
<Username
userId={userId}
showAt
className="text-highlight font-normal inline"
className={cn('text-primary font-normal inline', className)}
withoutSkeleton
/>
)
}
export function EmbeddedMentionText({ userId }: { userId: string }) {
return <SimpleUsername userId={userId} showAt className="inline truncate" withoutSkeleton />
}
export const embeddedNostrNpubRenderer: TEmbeddedRenderer = {
regex: /(nostr:npub1[a-z0-9]{58})/g,
render: (id: string, index: number) => {
const npub1 = id.split(':')[1]
return <EmbeddedMention key={`embedded-nostr-npub-${index}-${npub1}`} userId={npub1} />
}
}
export const embeddedNostrProfileRenderer: TEmbeddedRenderer = {
regex: /(nostr:nprofile1[a-z0-9]+)/g,
render: (id: string, index: number) => {
const nprofile = id.split(':')[1]
return <EmbeddedMention key={`embedded-nostr-profile-${index}-${nprofile}`} userId={nprofile} />
}
}
export const embeddedNpubRenderer: TEmbeddedRenderer = {
regex: /(npub1[a-z0-9]{58})/g,
render: (npub1: string, index: number) => {
return <EmbeddedMention key={`embedded-npub-${index}-${npub1}`} userId={npub1} />
}
}
export const embeddedNostrNpubTextRenderer: TEmbeddedRenderer = {
regex: /(nostr:npub1[a-z0-9]{58})/g,
render: (id: string, index: number) => {
const npub1 = id.split(':')[1]
return <EmbeddedMentionText key={`embedded-nostr-npub-text-${index}-${npub1}`} userId={npub1} />
}
}
export const embeddedNostrProfileTextRenderer: TEmbeddedRenderer = {
regex: /(nostr:nprofile1[a-z0-9]+)/g,
render: (id: string, index: number) => {
const nprofile = id.split(':')[1]
return (
<EmbeddedMentionText
key={`embedded-nostr-profile-text-${index}-${nprofile}`}
userId={nprofile}
/>
)
}
export function EmbeddedMentionText({ userId, className }: { userId: string; className?: string }) {
return (
<SimpleUsername userId={userId} showAt className={cn('inline', className)} withoutSkeleton />
)
}

View File

@@ -1,22 +0,0 @@
import { TEmbeddedRenderer } from './types'
export function EmbeddedNormalUrl({ url }: { url: string }) {
return (
<a
className="text-highlight hover:underline"
href={url}
target="_blank"
onClick={(e) => e.stopPropagation()}
rel="noreferrer"
>
{url}
</a>
)
}
export const embeddedNormalUrlRenderer: TEmbeddedRenderer = {
regex: /(https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]+)/gu,
render: (url: string, index: number) => {
return <EmbeddedNormalUrl key={`normal-url-${index}-${url}`} url={url} />
}
}

View File

@@ -1,11 +1,9 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent } from '@/hooks'
import { cn } from '@/lib/utils'
import { Check, Copy } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import GenericNoteCard from '../NoteCard/GenericNoteCard'
import ClientSelect from '../ClientSelect'
import MainNoteCard from '../NoteCard/MainNoteCard'
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
const { event, isFetching } = useFetchEvent(noteId)
@@ -19,7 +17,7 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
}
return (
<GenericNoteCard
<MainNoteCard
className={cn('w-full', className)}
event={event}
embedded
@@ -31,12 +29,15 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
return (
<div
className={cn('text-left p-2 sm:p-3 border rounded-lg', className)}
className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-2">
<Skeleton className="w-7 h-7 rounded-full" />
<Skeleton className="h-3 w-16 my-1" />
<Skeleton className="w-9 h-9 rounded-full" />
<div>
<Skeleton className="h-3 w-16 my-1" />
<Skeleton className="h-3 w-16 my-1" />
</div>
</div>
<Skeleton className="w-full h-4 my-1 mt-2" />
<Skeleton className="w-2/3 h-4 my-1" />
@@ -46,23 +47,12 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) {
function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
return (
<div className={cn('text-left p-2 sm:p-3 border rounded-lg', className)}>
<div className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}>
<div className="flex flex-col items-center text-muted-foreground font-medium gap-2">
<div>{t('Sorry! The note cannot be found 😔')}</div>
<Button
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(noteId)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
}}
variant="ghost"
>
{isCopied ? <Check /> : <Copy />} {t('Copy event ID')}
</Button>
<ClientSelect className="w-full mt-2" originalNoteId={noteId} />
</div>
</div>
)

View File

@@ -1,26 +1,18 @@
import { useSecondaryPage } from '@/PageManager'
import { toRelay } from '@/lib/link'
import { TEmbeddedRenderer } from './types'
export function EmbeddedWebsocketUrl({ url }: { url: string }) {
const { push } = useSecondaryPage()
return (
<span
className="cursor-pointer px-1 text-highlight hover:bg-highlight/20"
className="cursor-pointer px-1 text-primary hover:bg-primary/20"
onClick={(e) => {
e.stopPropagation()
push(toRelay(url))
}}
>
[ {url} ]
<span className="w-2 h-1 bg-highlight" />
<span className="w-2 h-1 bg-primary" />
</span>
)
}
export const embeddedWebsocketUrlRenderer: TEmbeddedRenderer = {
regex: /(wss?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]+)/gu,
render: (url: string, index: number) => {
return <EmbeddedWebsocketUrl key={`websocket-url-${index}-${url}`} url={url} />
}
}

View File

@@ -1,18 +1,5 @@
export * from './EmbeddedHashtag'
export * from './EmbeddedLNInvoice'
export * from './EmbeddedMention'
export * from './EmbeddedNormalUrl'
export * from './EmbeddedNote'
export * from './EmbeddedWebsocketUrl'
import reactStringReplace from 'react-string-replace'
import { TEmbeddedRenderer } from './types'
export function embedded(content: string, renderers: TEmbeddedRenderer[]) {
let nodes: React.ReactNode[] = [content]
renderers.forEach((renderer) => {
nodes = reactStringReplace(nodes, renderer.regex, renderer.render)
})
return nodes
}

View File

@@ -1,4 +0,0 @@
export type TEmbeddedRenderer = {
regex: RegExp
render: (match: string, index: number) => JSX.Element
}

View File

@@ -0,0 +1,46 @@
import { cn } from '@/lib/utils'
import { TEmoji } from '@/types'
import { Heart } from 'lucide-react'
import { HTMLAttributes, useState } from 'react'
export default function Emoji({
emoji,
classNames
}: Omit<HTMLAttributes<HTMLDivElement>, 'className'> & {
emoji: TEmoji | string
classNames?: {
text?: string
img?: string
}
}) {
const [hasError, setHasError] = useState(false)
if (typeof emoji === 'string') {
return emoji === '+' ? (
<Heart className={cn('size-5 text-red-400 fill-red-400', classNames?.img)} />
) : (
<span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span>
)
}
if (hasError) {
return (
<span className={cn('whitespace-nowrap', classNames?.text)}>{`:${emoji.shortcode}:`}</span>
)
}
return (
<img
src={emoji.url}
alt={emoji.shortcode}
draggable={false}
className={cn('inline-block size-5 rounded-sm pointer-events-none', classNames?.img)}
onLoad={() => {
setHasError(false)
}}
onError={() => {
setHasError(true)
}}
/>
)
}

View File

@@ -0,0 +1,86 @@
import { useFetchEvent } from '@/hooks'
import { generateBech32IdFromATag } from '@/lib/tag'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const SHOW_COUNT = 10
export default function EmojiPackList() {
const { t } = useTranslation()
const { userEmojiListEvent } = useNostr()
const eventIds = useMemo(() => {
if (!userEmojiListEvent) return []
return (
userEmojiListEvent.tags
.map((tag) => (tag[0] === 'a' ? generateBech32IdFromATag(tag) : null))
.filter(Boolean) as `naddr1${string}`[]
).reverse()
}, [userEmojiListEvent])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = () => {
if (showCount < eventIds.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [showCount, eventIds])
if (eventIds.length === 0) {
return (
<div className="mt-2 text-sm text-center text-muted-foreground">
{t('no emoji packs found')}
</div>
)
}
return (
<div>
{eventIds.slice(0, showCount).map((eventId) => (
<EmojiPackNote key={eventId} eventId={eventId} />
))}
</div>
)
}
function EmojiPackNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton className="border-b" />
}
if (!event) {
return null
}
return <NoteCard event={event} className="w-full" />
}

View File

@@ -0,0 +1,48 @@
import { parseEmojiPickerUnified } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useTheme } from '@/providers/ThemeProvider'
import customEmojiService from '@/services/custom-emoji.service'
import { TEmoji } from '@/types'
import EmojiPickerReact, {
EmojiStyle,
SkinTonePickerLocation,
SuggestionMode,
Theme
} from 'emoji-picker-react'
export default function EmojiPicker({
onEmojiClick
}: {
onEmojiClick: (emoji: string | TEmoji | undefined, event: MouseEvent) => void
}) {
const { themeSetting } = useTheme()
const { isSmallScreen } = useScreenSize()
return (
<EmojiPickerReact
theme={
themeSetting === 'system' ? Theme.AUTO : themeSetting === 'dark' ? Theme.DARK : Theme.LIGHT
}
width={isSmallScreen ? '100%' : 350}
autoFocusSearch={false}
emojiStyle={EmojiStyle.NATIVE}
skinTonePickerLocation={SkinTonePickerLocation.PREVIEW}
style={
{
'--epr-bg-color': 'hsl(var(--background))',
'--epr-category-label-bg-color': 'hsl(var(--background))',
'--epr-text-color': 'hsl(var(--foreground))',
'--epr-hover-bg-color': 'hsl(var(--muted) / 0.5)',
'--epr-picker-border-color': 'transparent',
'--epr-search-input-bg-color': 'hsl(var(--muted) / 0.5)'
} as React.CSSProperties
}
suggestedEmojisMode={SuggestionMode.FREQUENT}
onEmojiClick={(data, e) => {
const emoji = parseEmojiPickerUnified(data.unified)
onEmojiClick(emoji, e)
}}
customEmojis={customEmojiService.getAllCustomEmojisForPicker()}
/>
)
}

View File

@@ -0,0 +1,53 @@
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TEmoji } from '@/types'
import { useState } from 'react'
import EmojiPicker from '../EmojiPicker'
export default function EmojiPickerDialog({
children,
onEmojiClick
}: {
children: React.ReactNode
onEmojiClick?: (emoji: string | TEmoji | undefined) => void
}) {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<EmojiPicker
onEmojiClick={(emoji, e) => {
e.stopPropagation()
setOpen(false)
onEmojiClick?.(emoji)
}}
/>
</DrawerContent>
</Drawer>
)
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0 w-fit">
<EmojiPicker
onEmojiClick={(emoji, e) => {
e.stopPropagation()
setOpen(false)
onEmojiClick?.(emoji)
}}
/>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,78 @@
import { Button } from '@/components/ui/button'
import { RotateCw } from 'lucide-react'
import React, { Component, ReactNode } from 'react'
interface ErrorBoundaryProps {
children: ReactNode
}
interface ErrorBoundaryState {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="w-screen h-screen flex flex-col items-center justify-center p-4 gap-4">
<h1 className="text-2xl font-bold">Oops, something went wrong.</h1>
<p className="text-lg text-center max-w-md">
Sorry for the inconvenience. If you don't mind helping, you can{' '}
<a
href="https://github.com/CodyTseng/jumble/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
submit an issue on GitHub
</a>{' '}
with the error details, or{' '}
<a
href="https://jumble.social/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
mention me
</a>
. Thank you for your support!
</p>
{this.state.error?.message && (
<>
<Button
onClick={() => {
navigator.clipboard.writeText(this.state.error!.message)
}}
variant="secondary"
>
Copy Error Message
</Button>
<pre className="bg-destructive/10 text-destructive p-2 rounded text-wrap break-words whitespace-pre-wrap">
Error: {this.state.error.message}
</pre>
</>
)}
<Button onClick={() => window.location.reload()} className="mt-2">
<RotateCw />
Reload Page
</Button>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,103 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchRelayInfo } from '@/hooks'
import { toRelay } from '@/lib/link'
import { recommendRelaysByLanguage } from '@/lib/relay'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import relayInfoService from '@/services/relay-info.service'
import { TAwesomeRelayCollection } from '@/types'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
export default function Explore() {
const { t, i18n } = useTranslation()
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
const recommendedRelays = useMemo(() => {
const lang = i18n.language
const relays = recommendRelaysByLanguage(lang)
return relays
}, [i18n.language])
useEffect(() => {
relayInfoService.getAwesomeRelayCollections().then(setCollections)
}, [])
if (!collections && recommendedRelays.length === 0) {
return (
<div>
<div className="p-4 max-md:border-b">
<Skeleton className="h-6 w-20" />
</div>
<div className="grid md:px-4 md:grid-cols-2 md:gap-2">
<RelaySimpleInfoSkeleton className="h-auto px-4 py-3 md:rounded-lg md:border" />
</div>
</div>
)
}
return (
<div className="space-y-6">
{recommendedRelays.length > 0 && (
<RelayCollection
collection={{
id: 'recommended',
name: t('Recommended'),
relays: recommendedRelays
}}
/>
)}
{collections &&
collections.map((collection) => (
<RelayCollection key={collection.id} collection={collection} />
))}
</div>
)
}
function RelayCollection({ collection }: { collection: TAwesomeRelayCollection }) {
const { deepBrowsing } = useDeepBrowsing()
return (
<div>
<div
className={cn(
'sticky bg-background z-20 px-4 py-3 text-2xl font-semibold max-md:border-b',
deepBrowsing ? 'top-12' : 'top-24'
)}
>
{collection.name}
</div>
<div className="grid md:px-4 md:grid-cols-2 md:gap-3">
{collection.relays.map((url) => (
<RelayItem key={url} url={url} />
))}
</div>
</div>
)
}
function RelayItem({ url }: { url: string }) {
const { push } = useSecondaryPage()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
if (isFetching) {
return <RelaySimpleInfoSkeleton className="h-auto px-4 py-3 border-b md:rounded-lg md:border" />
}
if (!relayInfo) {
return null
}
return (
<RelaySimpleInfo
key={relayInfo.url}
className="clickable h-auto px-4 py-3 border-b md:rounded-lg md:border"
relayInfo={relayInfo}
onClick={(e) => {
e.stopPropagation()
push(toRelay(relayInfo.url))
}}
/>
)
}

View File

@@ -0,0 +1,94 @@
import {
EmbeddedHashtagParser,
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { cn } from '@/lib/utils'
import { useMemo } from 'react'
import { EmbeddedHashtag, EmbeddedLNInvoice, EmbeddedWebsocketUrl } from '../Embedded'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview'
import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
export default function ExternalContent({
content,
className,
mustLoadMedia
}: {
content?: string
className?: string
mustLoadMedia?: boolean
}) {
const nodes = useMemo(() => {
if (!content) return []
return parseContent(content, [
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
EmbeddedHashtagParser
])
}, [content])
if (!nodes || nodes.length === 0) {
return null
}
const node = nodes[0]
if (node.type === 'text') {
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>{content}</div>
)
}
if (node.type === 'url') {
return <WebPreview url={node.data} className={className} mustLoad={mustLoadMedia} />
}
if (node.type === 'x-post') {
return (
<XEmbeddedPost
url={node.data}
className={className}
mustLoad={mustLoadMedia}
embedded={false}
/>
)
}
if (node.type === 'youtube') {
return <YoutubeEmbeddedPlayer url={node.data} className={className} mustLoad={mustLoadMedia} />
}
if (node.type === 'image' || node.type === 'images') {
const data = Array.isArray(node.data) ? node.data : [node.data]
return (
<ImageGallery
className={className}
images={data.map((url) => ({ url }))}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'media') {
return <MediaPlayer className={className} src={node.data} mustLoad={mustLoadMedia} />
}
if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} className={className} />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} />
}
return null
}

View File

@@ -0,0 +1,64 @@
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { useRef, useEffect, useState } from 'react'
export type TTabValue = 'replies' | 'reactions' | 'quotes'
const TABS = [
{ value: 'replies', label: 'Replies' },
{ value: 'reactions', label: 'Reactions' },
{ value: 'quotes', label: 'Quotes' }
] as { value: TTabValue; label: string }[]
export function Tabs({
selectedTab,
onTabChange
}: {
selectedTab: TTabValue
onTabChange: (tab: TTabValue) => void
}) {
const { t } = useTranslation()
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 })
useEffect(() => {
setTimeout(() => {
const activeIndex = TABS.findIndex((tab) => tab.value === selectedTab)
if (activeIndex >= 0 && tabRefs.current[activeIndex]) {
const activeTab = tabRefs.current[activeIndex]
const { offsetWidth, offsetLeft } = activeTab
const padding = 32 // 16px padding on each side
setIndicatorStyle({
width: offsetWidth - padding,
left: offsetLeft + padding / 2
})
}
}, 20) // ensure tabs are rendered before calculating
}, [selectedTab])
return (
<div className="w-fit">
<div className="flex relative">
{TABS.map((tab, index) => (
<div
key={tab.value}
ref={(el) => (tabRefs.current[index] = el)}
className={cn(
`text-center px-4 py-2 font-semibold clickable cursor-pointer rounded-lg`,
selectedTab === tab.value ? '' : 'text-muted-foreground'
)}
onClick={() => onTabChange(tab.value)}
>
{t(tab.label)}
</div>
))}
<div
className="absolute bottom-0 h-1 bg-primary rounded-full transition-all duration-500"
style={{
width: `${indicatorStyle.width}px`,
left: `${indicatorStyle.left}px`
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { useState } from 'react'
import HideUntrustedContentButton from '../HideUntrustedContentButton'
import QuoteList from '../QuoteList'
import ReactionList from '../ReactionList'
import ReplyNoteList from '../ReplyNoteList'
import { Tabs, TTabValue } from './Tabs'
export default function ExternalContentInteractions({
externalContent
}: {
externalContent: string
}) {
const [type, setType] = useState<TTabValue>('replies')
let list
switch (type) {
case 'replies':
list = <ReplyNoteList stuff={externalContent} />
break
case 'reactions':
list = <ReactionList stuff={externalContent} />
break
case 'quotes':
list = <QuoteList stuff={externalContent} />
break
default:
break
}
return (
<>
<div className="flex items-center justify-between">
<ScrollArea className="flex-1 w-0">
<Tabs selectedTab={type} onTabChange={setType} />
<ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
</ScrollArea>
<Separator orientation="vertical" className="h-6" />
<div className="size-10 flex items-center justify-center">
<HideUntrustedContentButton type="interactions" />
</div>
</div>
<Separator />
{list}
</>
)
}

View File

@@ -0,0 +1,135 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { toExternalContent } from '@/lib/link'
import { truncateUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ExternalLink as ExternalLinkIcon, MessageSquare } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function ExternalLink({
url,
className,
justOpenLink
}: {
url: string
className?: string
justOpenLink?: boolean
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const displayUrl = useMemo(() => truncateUrl(url), [url])
const handleOpenLink = (e: React.MouseEvent) => {
e.stopPropagation()
if (isSmallScreen) {
setIsDrawerOpen(false)
}
window.open(url, '_blank', 'noreferrer')
}
const handleViewDiscussions = (e: React.MouseEvent) => {
e.stopPropagation()
if (isSmallScreen) {
setIsDrawerOpen(false)
setTimeout(() => push(toExternalContent(url)), 100) // wait for drawer to close
return
}
push(toExternalContent(url))
}
if (justOpenLink) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className={cn('cursor-pointer text-primary hover:underline', className)}
onClick={(e) => e.stopPropagation()}
>
{displayUrl}
</a>
)
}
const trigger = (
<span
className={cn('cursor-pointer text-primary hover:underline', className)}
onClick={(e) => {
e.stopPropagation()
if (isSmallScreen) {
setIsDrawerOpen(true)
}
}}
title={url}
>
{displayUrl}
</span>
)
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay
onClick={(e) => {
e.stopPropagation()
setIsDrawerOpen(false)
}}
/>
<DrawerContent hideOverlay>
<div className="py-2">
<Button
onClick={handleOpenLink}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<ExternalLinkIcon />
{t('Open link')}
</Button>
<Button
onClick={handleViewDiscussions}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<MessageSquare />
{t('View Nostr discussions')}
</Button>
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger>
<span className={cn('cursor-pointer text-primary hover:underline', className)} title={url}>
{displayUrl}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={handleOpenLink}>
<ExternalLinkIcon />
{t('Open link')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleViewDiscussions}>
<MessageSquare />
{t('View Nostr discussions')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,34 @@
import { faviconUrl } from '@/lib/faviconUrl'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useState } from 'react'
export function Favicon({
domain,
className,
fallback = null
}: {
domain: string
className?: string
fallback?: React.ReactNode
}) {
const { faviconUrlTemplate } = useContentPolicy()
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
if (error) return fallback
const url = faviconUrl(faviconUrlTemplate, `https://${domain}`)
return (
<div className={cn('relative', className)}>
{loading && <div className={cn('absolute inset-0', className)}>{fallback}</div>}
<img
src={url}
alt={domain}
className={cn('absolute inset-0', loading && 'opacity-0', className)}
onError={() => setError(true)}
onLoad={() => setLoading(false)}
/>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function AddNewRelay() {
const { t } = useTranslation()
const { favoriteRelays, addFavoriteRelays } = useFavoriteRelays()
const [input, setInput] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const saveRelay = async () => {
if (!input) return
const normalizedUrl = normalizeUrl(input)
if (!normalizedUrl) {
setErrorMsg(t('Invalid URL'))
return
}
if (favoriteRelays.includes(normalizedUrl)) {
setErrorMsg(t('Already saved'))
return
}
await addFavoriteRelays([normalizedUrl])
setInput('')
}
const handleNewRelayInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value)
setErrorMsg('')
}
const handleNewRelayInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
saveRelay()
}
}
return (
<div className="space-y-1">
<div className="flex gap-2 items-center">
<Input
placeholder={t('Add a new relay')}
value={input}
onChange={handleNewRelayInputChange}
onKeyDown={handleNewRelayInputKeyDown}
className={errorMsg ? 'border-destructive' : ''}
/>
<Button onClick={saveRelay}>{t('Add')}</Button>
</div>
{errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>}
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function AddNewRelaySet() {
const { t } = useTranslation()
const { createRelaySet } = useFavoriteRelays()
const [newRelaySetName, setNewRelaySetName] = useState('')
const saveRelaySet = () => {
if (!newRelaySetName) return
createRelaySet(newRelaySetName)
setNewRelaySetName('')
}
const handleNewRelaySetNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewRelaySetName(e.target.value)
}
const handleNewRelaySetNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
saveRelaySet()
}
}
return (
<div className="space-y-1">
<div className="flex gap-2 items-center">
<Input
placeholder={t('Add a new relay set')}
value={newRelaySetName}
onChange={handleNewRelaySetNameChange}
onKeyDown={handleNewRelaySetNameKeyDown}
/>
<Button onClick={saveRelaySet}>{t('Add')}</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core'
import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { useTranslation } from 'react-i18next'
import RelayItem from './RelayItem'
export default function FavoriteRelayList() {
const { t } = useTranslation()
const { favoriteRelays, reorderFavoriteRelays } = useFavoriteRelays()
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = favoriteRelays.findIndex((relay) => relay === active.id)
const newIndex = favoriteRelays.findIndex((relay) => relay === over.id)
const reorderedRelays = arrayMove(favoriteRelays, oldIndex, newIndex)
reorderFavoriteRelays(reorderedRelays)
}
}
return (
<div className="space-y-2">
<div className="text-muted-foreground font-semibold select-none">{t('Relays')}</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext items={favoriteRelays} strategy={verticalListSortingStrategy}>
<div className="grid gap-2">
{favoriteRelays.map((relay) => (
<RelayItem key={relay} relay={relay} />
))}
</div>
</SortableContext>
</DndContext>
</div>
)
}

View File

@@ -16,29 +16,35 @@ import {
DrawerTrigger
} from '@/components/ui/drawer'
import { BIG_RELAY_URLS } from '@/constants'
import { getReplaceableEventIdentifier } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag'
import { isWebsocketUrl, simplifyUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useRelaySets } from '@/providers/RelaySetsProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import { TRelaySet } from '@/types'
import { CloudDownload } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelaySetCard from '../RelaySetCard'
import { buildATag } from '@/lib/draft-event'
export default function PullFromRelaysButton() {
export default function PullRelaySetsButton() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
const trigger = (
<Button variant="secondary" className="w-full" disabled={!pubkey}>
<Button
variant="link"
className="text-muted-foreground hover:no-underline hover:text-foreground p-0 h-fit"
disabled={!pubkey}
>
<CloudDownload />
{t('Pull from relays')}
{t('Pull relay sets')}
</Button>
)
@@ -76,8 +82,9 @@ export default function PullFromRelaysButton() {
function RemoteRelaySets({ close }: { close?: () => void }) {
const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
const { mergeRelaySets } = useRelaySets()
const { addRelaySets, relaySets: existingRelaySets } = useFavoriteRelays()
const [initialed, setInitialed] = useState(false)
const [relaySetEventMap, setRelaySetEventMap] = useState<Map<string, Event>>(new Map())
const [relaySets, setRelaySets] = useState<TRelaySet[]>([])
const [selectedRelaySetIds, setSelectedRelaySetIds] = useState<string[]>([])
@@ -96,10 +103,11 @@ function RemoteRelaySets({ close }: { close?: () => void }) {
)
events.sort((a, b) => b.created_at - a.created_at)
const relaySetIds = new Set<string>()
const relaySetIds = new Set<string>(existingRelaySets.map((r) => r.id))
const relaySets: TRelaySet[] = []
const relaySetEventMap = new Map<string, Event>()
events.forEach((evt) => {
const id = evt.tags.find(tagNameEquals('d'))?.[1]
const id = getReplaceableEventIdentifier(evt)
if (!id || relaySetIds.has(id)) return
relaySetIds.add(id)
@@ -113,10 +121,12 @@ function RemoteRelaySets({ close }: { close?: () => void }) {
if (!title) {
title = relayUrls.length === 1 ? simplifyUrl(relayUrls[0]) : id
}
relaySets.push({ id, name: title, relayUrls })
relaySets.push({ id, name: title, relayUrls, aTag: buildATag(evt) })
relaySetEventMap.set(id, evt)
})
setRelaySets(relaySets)
setRelaySetEventMap(relaySetEventMap)
setInitialed(true)
}
init()
@@ -158,8 +168,11 @@ function RemoteRelaySets({ close }: { close?: () => void }) {
className="w-full"
disabled={!selectedRelaySetIds.length}
onClick={() => {
if (selectedRelaySetIds.length > 0) {
mergeRelaySets(relaySets.filter((set) => selectedRelaySetIds.includes(set.id)))
const selectedRelaySets = selectedRelaySetIds
.map((id) => relaySetEventMap.get(id))
.filter(Boolean) as Event[]
if (selectedRelaySets.length > 0) {
addRelaySets(selectedRelaySets)
close?.()
}
}}

View File

@@ -0,0 +1,44 @@
import { toRelay } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical } from 'lucide-react'
import RelayIcon from '../RelayIcon'
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
export default function RelayItem({ relay }: { relay: string }) {
const { push } = useSecondaryPage()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: relay
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1
}
return (
<div
className="relative group clickable flex gap-2 border rounded-lg p-2 pr-2.5 items-center justify-between select-none"
ref={setNodeRef}
style={style}
onClick={() => push(toRelay(relay))}
>
<div className="flex items-center gap-1 flex-1">
<div
className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none shrink-0"
{...attributes}
{...listeners}
>
<GripVertical className="size-4 text-muted-foreground" />
</div>
<div className="flex gap-2 items-center flex-1">
<RelayIcon url={relay} />
<div className="flex-1 w-0 truncate font-semibold">{relay}</div>
</div>
</div>
<SaveRelayDropdownMenu urls={[relay]} />
</div>
)
}

View File

@@ -0,0 +1,211 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TRelaySet } from '@/types'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
Check,
ChevronDown,
Edit,
EllipsisVertical,
FolderClosed,
GripVertical,
Link,
Trash2
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import DrawerMenuItem from '../DrawerMenuItem'
import RelayUrls from './RelayUrl'
import { useRelaySetsSettingComponent } from './provider'
export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) {
const { t } = useTranslation()
const { expandedRelaySetId } = useRelaySetsSettingComponent()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: relaySet.id
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1
}
return (
<div ref={setNodeRef} style={style} className="relative group">
<div className="w-full border rounded-lg px-2 py-2.5">
<div className="flex justify-between items-center">
<div className="flex items-center">
<div
className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none"
{...attributes}
{...listeners}
>
<GripVertical className="size-4 text-muted-foreground" />
</div>
<div className="flex gap-2 items-center">
<div className="flex justify-center items-center w-6 h-6 shrink-0">
<FolderClosed className="size-4" />
</div>
<RelaySetName relaySet={relaySet} />
</div>
</div>
<div className="flex gap-1">
<RelayUrlsExpandToggle relaySetId={relaySet.id}>
{t('n relays', { n: relaySet.relayUrls.length })}
</RelayUrlsExpandToggle>
<RelaySetOptions relaySet={relaySet} />
</div>
</div>
{expandedRelaySetId === relaySet.id && <RelayUrls relaySetId={relaySet.id} />}
</div>
</div>
)
}
function RelaySetName({ relaySet }: { relaySet: TRelaySet }) {
const [newSetName, setNewSetName] = useState(relaySet.name)
const { updateRelaySet } = useFavoriteRelays()
const { renamingRelaySetId, setRenamingRelaySetId } = useRelaySetsSettingComponent()
const saveNewRelaySetName = () => {
if (relaySet.name === newSetName) {
return setRenamingRelaySetId(null)
}
updateRelaySet({ ...relaySet, name: newSetName })
setRenamingRelaySetId(null)
}
const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewSetName(e.target.value)
}
const handleRenameInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
saveNewRelaySetName()
}
}
return renamingRelaySetId === relaySet.id ? (
<div className="flex gap-1 items-center">
<Input
value={newSetName}
onChange={handleRenameInputChange}
onBlur={saveNewRelaySetName}
onKeyDown={handleRenameInputKeyDown}
className="font-semibold w-28"
/>
<Button variant="ghost" size="icon" onClick={saveNewRelaySetName}>
<Check size={18} className="text-green-500" />
</Button>
</div>
) : (
<div className="h-8 font-semibold flex items-center select-none">{relaySet.name}</div>
)
}
function RelayUrlsExpandToggle({
relaySetId,
children
}: {
relaySetId: string
children: React.ReactNode
}) {
const { expandedRelaySetId, setExpandedRelaySetId } = useRelaySetsSettingComponent()
return (
<div
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
onClick={() => setExpandedRelaySetId((pre) => (pre === relaySetId ? null : relaySetId))}
>
<div className="select-none">{children}</div>
<ChevronDown
size={16}
className={`transition-transform duration-200 ${expandedRelaySetId === relaySetId ? 'rotate-180' : ''}`}
/>
</div>
)
}
function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { deleteRelaySet } = useFavoriteRelays()
const { setRenamingRelaySetId } = useRelaySetsSettingComponent()
const trigger = (
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
)
const rename = () => {
setRenamingRelaySetId(relaySet.id)
}
const copyShareLink = () => {
navigator.clipboard.writeText(
`https://jumble.social/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}`
)
}
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent>
<div className="py-2">
<DrawerMenuItem onClick={rename}>
<Edit />
{t('Rename')}
</DrawerMenuItem>
<DrawerMenuItem onClick={copyShareLink}>
<Link />
{t('Copy share link')}
</DrawerMenuItem>
<DrawerMenuItem
className="text-destructive focus:text-destructive"
onClick={() => deleteRelaySet(relaySet.id)}
>
<Trash2 />
{t('Delete')}
</DrawerMenuItem>
</div>
</DrawerContent>
</Drawer>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={rename}>
<Edit />
{t('Rename')}
</DropdownMenuItem>
<DropdownMenuItem onClick={copyShareLink}>
<Link />
{t('Copy share link')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => deleteRelaySet(relaySet.id)}
>
<Trash2 />
{t('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,72 @@
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core'
import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { useTranslation } from 'react-i18next'
import PullRelaySetsButton from './PullRelaySetsButton'
import RelaySet from './RelaySet'
export default function RelaySetList() {
const { t } = useTranslation()
const { relaySets, reorderRelaySets } = useFavoriteRelays()
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = relaySets.findIndex((item) => item.id === active.id)
const newIndex = relaySets.findIndex((item) => item.id === over.id)
const reorderedSets = arrayMove(relaySets, oldIndex, newIndex)
reorderRelaySets(reorderedSets)
}
}
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-muted-foreground font-semibold select-none shrink-0">
{t('Relay sets')}
</div>
<PullRelaySetsButton />
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={relaySets.map((set) => set.id)}
strategy={verticalListSortingStrategy}
>
<div className="grid gap-2">
{relaySets.map((relaySet) => (
<RelaySet key={relaySet.id} relaySet={relaySet} />
))}
</div>
</SortableContext>
</DndContext>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More