867 Commits

Author SHA1 Message Date
codytseng
f66229f417 feat: swipe 2025-12-17 18:19:52 +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
Cody Tseng
8fa419ef28 chore: update bitcoin-connect (#225) 2025-03-08 11:35:59 +08:00
codytseng
094ccd92da feat: handle user cancellation in zap process 2025-03-08 11:35:23 +08:00
codytseng
5e3cd04280 feat: improve the preview effect when note is not found 2025-03-08 00:06:31 +08:00
Cody Tseng
accf3319e7 style: adjust the style of NoteStats (#222) 2025-03-07 23:39:46 +08:00
bitcoinuser
71895e3a0f feat: update pt-BR translations 2025-03-06 09:39:16 +08:00
bitcoinuser
7b2c77d55e feat: update pt-BR translations 2025-03-05 23:28:32 +08:00
codytseng
407a5a3f78 style: adjust the position of new notification indicator 2025-03-05 23:20:49 +08:00
Maxblake
76da5b3af1 fix: correct Polish grammar (#201)
A small correction of Polish grammar
2025-03-04 10:24:25 +08:00
codytseng
477f6eebdc fix: 🐛 2025-03-04 09:36:55 +08:00
codytseng
7e5328f2b1 fix: add missing translations 2025-03-03 20:48:01 +08:00
codytseng
264e188e48 fix: remove self from mentions 2025-03-03 20:33:02 +08:00
codytseng
8f8f595940 feat: increase PostEditor dialog width 2025-03-03 20:30:12 +08:00
codytseng
94f35be93e feat: list recent supporters 2025-03-03 20:08:34 +08:00
codytseng
55bd996970 fix: 🐛 2025-03-03 11:37:35 +08:00
codytseng
08d055035b docs: update README 2025-03-03 11:03:18 +08:00
codytseng
aa94f938c0 feat: reorder languages according to language codes 2025-03-03 11:01:46 +08:00
codytseng
2c9c2c0de8 feat: display zap request error message 2025-03-03 10:34:18 +08:00
bitcoinuser
484b8bcb36 feat: update pt-BR 2025-03-03 09:50:53 +08:00
codytseng
5e7c852624 feat: add support for pt-BR and pt-PT 2025-03-02 23:02:59 +08:00
codytseng
36240edc12 feat: enhance FollowButton 2025-03-02 22:48:50 +08:00
codytseng
186b23386a docs: add screenshots 2025-03-02 19:14:01 +08:00
codytseng
e81ab6d0bd refactor: i18n 2025-03-02 16:36:18 +08:00
codytseng
a2dd56d475 docs: update README 2025-03-02 16:20:37 +08:00
codytseng
966861f305 feat: support more languages 2025-03-02 15:31:32 +08:00
codytseng
d79f5d0722 feat: filter new notifications from muted users 2025-03-02 11:57:43 +08:00
Maxblake
616c5bdd50 feat: add polish language (#190) 2025-03-02 11:51:18 +08:00
Cody Tseng
249593d547 feat: zap (#107) 2025-03-01 23:52:05 +08:00
codytseng
407a6fb802 style: show new button 2025-02-28 11:37:12 +08:00
codytseng
f823ef4db0 style: show new button 2025-02-27 22:45:36 +08:00
codytseng
979d5e9ecc feat: mention all users in the thread by default 2025-02-27 22:01:17 +08:00
codytseng
d4fa40900b fix: close mentions dropdown on mobile 2025-02-27 14:36:18 +08:00
codytseng
3c23a7f9f8 feat: support customizing mentioned users 2025-02-26 22:49:43 +08:00
codytseng
c1d469b1f4 fix: remove unnecessary tags from reaction events 2025-02-26 20:36:05 +08:00
codytseng
ecd7e6d7db fix: hide the content of muted users' notes in the parent note preview 2025-02-25 23:18:12 +08:00
codytseng
2f9be97178 fix: catch some errors 2025-02-25 17:54:01 +08:00
codytseng
4994b26520 feat: remove kind 20 events from the main feed 2025-02-25 15:33:19 +08:00
codytseng
68fffa0af2 refactor: adjust skeleton 2025-02-25 15:33:19 +08:00
codytseng
17cf67228f feat: add loading animation for followings and relays count 2025-02-24 23:13:41 +08:00
codytseng
3cb24e1f5f fix: modify the color of the username in the preview 2025-02-24 22:57:38 +08:00
codytseng
dbe7db7160 style: increase z-index of BottomNavigationBar 2025-02-24 14:18:46 +08:00
codytseng
212a4ac103 feat: improve parent note preview 2025-02-24 12:39:02 +08:00
codytseng
e6516d7acd feat: improve the relays used for sending replies 2025-02-23 23:35:56 +08:00
codytseng
e7cb7342aa feat: 🏗️ 2025-02-23 15:58:49 +08:00
codytseng
0f7a3aa593 feat: nip05 skeleton 2025-02-22 01:14:13 +08:00
codytseng
08f22073bd style: adjust size of top bar icons 2025-02-22 01:13:41 +08:00
codytseng
09f44fc1a0 fix: 🐛 2025-02-20 00:08:51 +08:00
codytseng
fe7d3a8b32 feat: 💨 2025-02-18 14:55:58 +08:00
codytseng
e61fd2e172 fix: 🐛 2025-02-17 15:41:48 +08:00
codytseng
f00ea38e17 fix: clear content when reposting protected events 2025-02-16 20:33:33 +08:00
codytseng
c4665fdc04 chore: remove unused console logs 2025-02-16 19:13:41 +08:00
codytseng
359d2488d3 fix: 🐛 2025-02-16 14:05:37 +08:00
codytseng
83ae874059 fix: 🎨 2025-02-15 22:38:45 +08:00
codytseng
6c63da2a96 feat: update nstart modal 2025-02-15 12:29:29 +08:00
codytseng
5fa50fecd3 feat: display relay url if no name 2025-02-14 21:47:51 +08:00
codytseng
978244dd22 perf: optimize list rendering 2025-02-14 21:44:13 +08:00
codytseng
41d46b1a13 feat: optimize display effect when image loading fails 2025-02-14 12:17:01 +08:00
codytseng
c4b9b397a6 feat: build username index on startup 2025-02-13 23:26:00 +08:00
codytseng
73b38d37e7 feat: optimize the display effect of other kinds of events 2025-02-13 22:56:09 +08:00
codytseng
5e3fd93a23 fix: bug of lost followers 2025-02-12 22:59:09 +08:00
Cody Tseng
d5f46690c4 perf: improve loading speed (#116) 2025-02-12 22:09:00 +08:00
codytseng
6f91c200a9 fix: 🐛 2025-02-12 18:27:34 +08:00
codytseng
22be4772db fix: 🐛 2025-02-11 23:57:59 +08:00
codytseng
2cde70dff4 feat: 💨 2025-02-11 22:46:08 +08:00
codytseng
8f5c3ac4d6 feat: update monitor relays 2025-02-11 17:10:35 +08:00
Cody Tseng
b91f46723e feat: explore (#85) 2025-02-11 16:33:31 +08:00
codytseng
80893ec033 feat :💨 2025-02-11 16:32:21 +08:00
codytseng
6e53641d69 fix: 🐛 2025-02-11 16:29:31 +08:00
codytseng
acd8348308 fix: 🐛 2025-02-11 11:08:08 +08:00
codytseng
d322f595fe feat: 💨 2025-02-11 10:15:05 +08:00
codytseng
ab1fff53ef fix: 🐛 2025-02-10 22:40:37 +08:00
codytseng
05cade1f99 feat: 💨 2025-02-10 22:27:58 +08:00
codytseng
9c0e30ec24 feat: 💨 2025-02-10 21:26:47 +08:00
codytseng
a555293e2f fix: 🐛 2025-02-10 00:05:42 +08:00
codytseng
9a643a09ab fix: 🎨 2025-02-09 22:13:10 +08:00
codytseng
05ca35feb1 fix: 🐛 2025-02-08 09:22:06 +08:00
codytseng
ec19b9cbfe feat: 💨 2025-02-07 22:56:00 +08:00
codytseng
5d21172017 feat: add hint to e tags 2025-02-06 22:52:44 +08:00
codytseng
b28ebbe278 feat: seen on button 2025-02-06 22:08:08 +08:00
codytseng
5b7a449938 fix: add padding 2025-02-06 21:31:06 +08:00
codytseng
aafa599b69 feat: 🎨 2025-02-06 17:04:01 +08:00
codytseng
9af2121efe fix: normalize user's website urls 2025-02-06 15:28:06 +08:00
codytseng
372e7a9976 feat: track event relays 2025-02-06 14:43:27 +08:00
codytseng
6ffa180812 feat: add more buttons to reply 2025-02-06 11:06:22 +08:00
codytseng
760f4bcccb fix: muted 2025-02-06 10:04:43 +08:00
codytseng
d1b7f140fd fix: resolve display issue of embedded kind 20 events 2025-02-05 23:31:49 +08:00
codytseng
af9b97f60c feat: 🎨 2025-02-05 15:31:15 +08:00
codytseng
8561ef038e feat: slow down 2025-02-05 15:26:21 +08:00
codytseng
ccf8c21954 feat: support sending only to current relays 2025-02-05 15:18:58 +08:00
codytseng
29f5ccc4bb feat: 🎨 2025-02-04 22:38:00 +08:00
codytseng
6ef4e960d6 fix: 🐛 2025-02-04 22:21:39 +08:00
codytseng
33c1a8c215 feat: 💨 2025-02-04 22:05:37 +08:00
codytseng
b292b3e3b5 feat: 💨 2025-02-03 22:45:15 +08:00
codytseng
97ccb3cb7c feat: 💨 2025-02-03 21:12:30 +08:00
codytseng
699f3a792d feat: improve mention search results 2025-02-03 16:17:14 +08:00
codytseng
faa7298f89 fix: 🐛 2025-02-01 14:45:42 +08:00
codytseng
4675f226ff fix: remove unused import 2025-02-01 14:28:23 +08:00
codytseng
6c049001f6 feat: display website link in profile 2025-02-01 14:23:36 +08:00
codytseng
7c34042da3 fix: some 🐛 2025-02-01 14:05:13 +08:00
Cody Tseng
a264b747e7 feat: integrate nstart (#33) 2025-01-29 15:32:26 +08:00
codytseng
7daa566cec feat: 💨 2025-01-26 22:45:04 +08:00
codytseng
dd5e7e1acb fix: 🐛 2025-01-26 22:10:14 +08:00
codytseng
8ef1afc4b9 fix: 🐛 2025-01-26 20:19:18 +08:00
codytseng
2873b9bdd8 feat: 💨 2025-01-26 17:48:03 +08:00
codytseng
c26490f16e feat: fix titlebar and bottom navigation bar 2025-01-26 16:39:16 +08:00
codytseng
23bf7fd005 feat: scroll to top when jumping to the current page 2025-01-26 16:28:47 +08:00
codytseng
82537f192b feat: show login button when viewing following feed without login 2025-01-26 15:01:12 +08:00
codytseng
acc47bad3d feat: scroll to top on list tab switch 2025-01-25 22:45:33 +08:00
codytseng
92b78d4573 feat: increase the number of relays used for finding user notes 2025-01-25 18:24:29 +08:00
codytseng
fb5fe0e631 feat: click reply to jump to details 2025-01-25 18:06:51 +08:00
codytseng
45d692fc4c fix: 🐛 2025-01-25 17:36:56 +08:00
codytseng
a8127db4f6 feat: 🎨 2025-01-24 17:43:52 +08:00
codytseng
1df975dfc6 feat: sticky list mode switcher 2025-01-24 16:53:01 +08:00
codytseng
ee21e19625 refactor: 🏗️ 2025-01-23 15:53:02 +08:00
codytseng
0f2f82b3ac fix: 🐛 2025-01-23 12:17:04 +08:00
codytseng
b15ce2c153 fix: 🐛 2025-01-23 12:01:27 +08:00
codytseng
86468e75cb feat: mentions 2025-01-23 11:01:49 +08:00
codytseng
c92545a22d fix: 🐛 2025-01-22 16:50:23 +08:00
codytseng
e9ebf1bc00 fix: attempt to fix issue of no parent reply 2025-01-21 22:58:22 +08:00
codytseng
d35199f693 feat: add news.utxo.one to default relay sets 2025-01-21 22:11:12 +08:00
fiatjaf_
1555f17747 feat: add more default relay lists (#19) 2025-01-21 20:32:44 +08:00
codytseng
dae923ee38 fix: 🐛 2025-01-21 14:49:51 +08:00
codytseng
59f126e960 fix: wait for extension 2025-01-21 09:37:43 +08:00
codytseng
81218054a0 fix: 🐛 2025-01-20 23:19:17 +08:00
codytseng
9bc5fcb642 fix: 🐛 2025-01-20 23:11:56 +08:00
codytseng
9db979c31d feat: calculate optimal read relays 2025-01-20 22:56:00 +08:00
codytseng
4211f831bf feat: remember the last used note list mode 2025-01-20 10:00:46 +08:00
codytseng
c7c17c2e76 fix: 🐛 2025-01-20 09:37:01 +08:00
codytseng
09b25d262c feat: update default relays 2025-01-19 22:24:58 +08:00
codytseng
df4d5e52ae feat: 💨 2025-01-19 22:17:49 +08:00
codytseng
9c0fa6257a feat: filter invalid pubkeys in the follow list 2025-01-19 14:58:11 +08:00
codytseng
cbae26e492 feat: mute 2025-01-19 14:40:05 +08:00
codytseng
34ff0cd314 refactor: 🏗️ 2025-01-18 22:46:02 +08:00
codytseng
fa455ba127 fix: 🐛 2025-01-18 22:22:51 +08:00
codytseng
25bd4337c8 feat: improve profile card 2025-01-18 18:07:18 +08:00
codytseng
35b0350728 feat: improve style of save relay button 2025-01-18 17:55:50 +08:00
codytseng
a85f636d24 feat: improve style of others relay list 2025-01-18 17:13:44 +08:00
codytseng
08995d957c refactor: 🏗️ 2025-01-18 15:43:57 +08:00
codytseng
49933ee4a2 feat: groups badge 2025-01-18 14:42:37 +08:00
codytseng
1644a92615 feat: 💨 2025-01-18 14:22:40 +08:00
codytseng
b2f111a4e7 fix: 🐛 2025-01-17 23:11:27 +08:00
codytseng
ed2a21a51f feat: 💨 2025-01-17 17:20:24 +08:00
codytseng
76dd184c14 feat: relay info 2025-01-17 17:04:05 +08:00
codytseng
64a5573969 feat: others relays 2025-01-17 12:07:22 +08:00
codytseng
6543f29529 feat: set background color as early as possible 2025-01-17 10:38:25 +08:00
codytseng
f1ceac09bb feat: update some regexes 2025-01-16 22:51:48 +08:00
codytseng
8fb32bd380 feat: add a easy way to add relay to specified set 2025-01-16 17:50:53 +08:00
codytseng
e2cdc27545 feat: support ncryptsec 2025-01-15 23:32:22 +08:00
codytseng
52daf39584 feat: add like button on picture note card 2025-01-15 17:10:57 +08:00
codytseng
92a0f9071f fix: 🐛 2025-01-15 17:01:57 +08:00
codytseng
4acc1203fe feat: enhance picture notes browsing experience on large screen 2025-01-15 16:44:33 +08:00
codytseng
8e567dd401 feat: add multi-image icon 2025-01-15 15:34:07 +08:00
codytseng
0b6b864436 feat: carousel dot 2025-01-15 15:23:09 +08:00
codytseng
a6ec01d1e8 fix: 🐛 2025-01-15 10:02:08 +08:00
codytseng
78629dd64f feat: generate new account & profile editor 2025-01-14 18:09:31 +08:00
codytseng
3f031da748 feat: 💨 2025-01-14 12:44:22 +08:00
codytseng
a3c6cf0c21 feat: bold the title of picture notes 2025-01-14 12:28:32 +08:00
codytseng
6182fc914e fix: 🐛 2025-01-14 11:55:43 +08:00
codytseng
35a22bd2ba fix: 🐛 2025-01-14 09:20:15 +08:00
codytseng
6bc2fde314 fix: 🐛 2025-01-13 23:56:41 +08:00
codytseng
c62a82f673 fix: 🐛 2025-01-13 23:22:19 +08:00
codytseng
d0350c6ad3 perf: nav bar avatar 2025-01-13 17:09:20 +08:00
codytseng
0f8a5403cd feat: add mailbox configuration 2025-01-13 16:53:07 +08:00
codytseng
9de3d4ed5b fix: post picture notes 2025-01-13 10:19:37 +08:00
codytseng
7ed9d06b8d fix: attempt to resolve login bugs 2025-01-12 20:52:56 +08:00
codytseng
862aeebbf9 chore: i18n 2025-01-12 20:46:07 +08:00
codytseng
5bf220fa5b feat: picture notes editor 2025-01-12 17:18:45 +08:00
codytseng
2aba89419e 💨 2025-01-12 01:28:26 +08:00
codytseng
d1b8b981dd fix: use big relays when no read relays 2025-01-12 00:14:08 +08:00
codytseng
a7eb203b8c fix: 🐛 2025-01-11 13:24:30 +08:00
codytseng
0522e3937f fix: 🐛 2025-01-11 13:20:20 +08:00
codytseng
92e338ea1e feat: store last feed type 2025-01-09 21:59:26 +08:00
codytseng
be7712948a feat: improve notification content 2025-01-09 14:49:52 +08:00
codytseng
9f0f39f480 💨 2025-01-08 23:17:47 +08:00
codytseng
fe815bcce5 fix: some 🐛 2025-01-08 21:30:29 +08:00
codytseng
dacaa4a75d feat: blurhash 2025-01-08 16:54:10 +08:00
codytseng
91977d6495 fix: some 🐛 2025-01-08 11:09:20 +08:00
codytseng
bfba681461 fix: bunker sign no response 2025-01-08 10:03:29 +08:00
codytseng
50658f6d91 feat: add imeta tags 2025-01-08 00:44:52 +08:00
codytseng
3e609f7236 💨 2025-01-08 00:16:07 +08:00
codytseng
5ffbb8ed70 feat: reduce picture event load limit 2025-01-07 23:56:05 +08:00
codytseng
7bd5b915eb feat: sync relay sets 2025-01-07 23:26:05 +08:00
codytseng
4343765aba feat: support kind 20 2025-01-07 23:19:35 +08:00
codytseng
4205e32d0f style: change background color 2025-01-06 23:20:10 +08:00
codytseng
be7081359d feat: default to wss:// when URL has no protocol 2025-01-04 23:30:54 +08:00
codytseng
14eee0240b fix: some 🐛 2025-01-04 12:55:55 +08:00
codytseng
72e1478e43 fix: some 🐛 2025-01-03 14:16:18 +08:00
codytseng
6a73adfd3d fix: some 🐛 2025-01-03 13:13:05 +08:00
codytseng
14fbf7a7be fix: some 🐛 2025-01-03 13:04:57 +08:00
codytseng
3429c83085 fix: update language options 2025-01-02 22:10:31 +08:00
codytseng
3946e603b3 feat: improve mobile experience 2025-01-02 21:57:14 +08:00
codytseng
8ec0d46d58 fix: search npub 2024-12-26 13:31:04 +08:00
codytseng
73e109c7c6 refactor: replace zIndex with display for page stacking 2024-12-25 12:45:35 +08:00
codytseng
8a30987ad1 feat: add description and keywords metadata to index 2024-12-25 12:40:53 +08:00
codytseng
61b7743b77 fix: repeated subscription 2024-12-24 22:42:11 +08:00
codytseng
109c4ef6f0 feat: enhance reply experience 2024-12-24 21:42:32 +08:00
codytseng
6b58d1673b feat: display client tag 2024-12-24 15:12:01 +08:00
codytseng
35e5f18424 fix: 🐛 2024-12-24 14:40:09 +08:00
codytseng
b5174df32c fix: 🐛 2024-12-24 13:08:40 +08:00
codytseng
31f70c2ab1 feat: add option to add client tag 2024-12-24 12:13:39 +08:00
codytseng
234319ef50 refactor: rename 2024-12-23 23:27:34 +08:00
codytseng
33ac5e60b6 feat: multi accounts 2024-12-23 23:22:49 +08:00
codytseng
ee0c702135 fix: 🐛 2024-12-22 22:49:14 +08:00
codytseng
e865baa795 feat: notification list pull to refresh 2024-12-22 22:24:59 +08:00
codytseng
00af3aab64 feat: add default search relays 2024-12-22 22:22:43 +08:00
codytseng
f2cfad50a9 feat: pull to refresh 2024-12-22 16:57:08 +08:00
codytseng
c5b0c0543a feat: pwa 2024-12-22 16:37:38 +08:00
codytseng
869e164469 chore: install @noble/hashes 2024-12-22 00:21:28 +08:00
codytseng
9561922e89 chore: rollback @radix-ui/react-scroll-area 2024-12-22 00:12:37 +08:00
codytseng
2b1e6fe8f5 refactor: remove electron-related code 2024-12-21 23:20:30 +08:00
codytseng
bed8df06e8 feat: show login dialog when relay requires auth 2024-12-19 22:34:24 +08:00
codytseng
414389c317 fix: note options button height 2024-12-19 14:36:14 +08:00
codytseng
909dd43358 fix: 💨 2024-12-19 13:23:57 +08:00
codytseng
5966e06029 fix: 🐛 2024-12-18 15:19:20 +08:00
codytseng
4a39941352 feat: bunker login 2024-12-18 14:54:35 +08:00
618 changed files with 59365 additions and 14096 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

View File

@@ -1,9 +0,0 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

View File

@@ -1,4 +0,0 @@
node_modules
dist
out
.gitignore

View File

@@ -1,14 +0,0 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'@electron-toolkit/eslint-config-ts/recommended',
'@electron-toolkit/eslint-config-prettier'
],
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-explicit-any': 'off'
}
}

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: [CodyTseng]

View File

@@ -1,57 +0,0 @@
name: Build/release
on:
push:
tags:
- v*.*.*
permissions:
contents: write
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-13, windows-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Dependencies
run: npm install
- name: build-linux
if: matrix.os == 'ubuntu-latest'
run: npm run build:linux
- name: build-mac
if: matrix.os == 'macos-13'
run: npm run build:mac
- name: build-win
if: matrix.os == 'windows-latest'
run: npm run build:win
- name: release
uses: softprops/action-gh-release@v2
with:
draft: true
files: |
dist/*.exe
dist/*.zip
dist/*.dmg
dist/*.AppImage
dist/*.snap
dist/*.deb
dist/*.rpm
dist/*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

27
.gitignore vendored
View File

@@ -1,5 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
out
dist-ssr
dev-dist
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.log*
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel

View File

@@ -2,5 +2,4 @@ out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
*.json

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}

39
.vscode/launch.json vendored
View File

@@ -1,39 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 60000,
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
}

11
.vscode/settings.json vendored
View File

@@ -1,11 +0,0 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

410
AGENTS.md Normal file
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
#### 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;"]

View File

@@ -1,6 +1,6 @@
MIT License
MIT LICENSE
Copyright (c) 2024 Cody Tseng
Copyright (c) 2025 Cody Tseng
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

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,31 +7,19 @@
# 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-Based Browsing:** Explore content directly through relays without following specific users
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
- **Relay Groups:** Easily manage and switch between relay groups
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions
- **Cross-Platform:** Available on macOS, Windows, Linux, and web browsers
## Forks
## Web Version
> Some interesting forks of Jumble.
You can use the web version of Jumble at [jumble.social](https://jumble.social).
- [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)
## Desktop Version
You can download the desktop version from the [release page](https://github.com/CodyTseng/jumble/releases). If you want to use Apple Silicon version, you need to build it from the source code.
Because the app is not signed, you may need to allow it to run in the system settings.
## Build from source
You can also build the app from the source code.
> Note: Node.js >= 20 is required.
## Run Locally
```bash
# Clone this repository
@@ -45,18 +31,38 @@ cd jumble
# Install dependencies
npm install
# Build the app
npm run build:mac
# or npm run build:win
# or npm run build:linux
# or npm run build:web
# Run the app
npm run dev
```
The executable file will be in the `dist` folder.
## Run Docker
```bash
# Clone this repository
git clone https://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 :) ⚡️ codytseng@getalby.com ⚡️
If you like this project, you can buy me a coffee :)
- **Lightning:** ⚡️ codytseng@getalby.com ⚡️
- **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht
- **Geyser:** https://geyser.fund/project/jumble
## License

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -1,17 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/renderer/src/assets/main.css",
"baseColor": "slate",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@renderer/components",
"utils": "@renderer/lib/utils"
}
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

44
docker-compose.dev.yml Normal file
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

@@ -1,34 +0,0 @@
appId: com.jumble.app
productName: jumble
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: jumble
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: codytseng
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false

View File

@@ -1,21 +0,0 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@common': resolve('src/common')
}
},
plugins: [react()]
}
})

30
eslint.config.js Normal file
View File

@@ -0,0 +1,30 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/explicit-function-return-type': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': 'off',
'react-hooks/exhaustive-deps': 'off'
}
}
)

36
index.html Normal file
View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Jumble</title>
<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="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
<meta property="og:url" content="https://jumble.social" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Jumble" />
<meta
property="og:description"
content="A user-friendly Nostr client for exploring relay feeds"
/>
<meta
property="og:image"
content="https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

12662
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{
"name": "jumble",
"version": "0.1.0",
"description": "Yet another Nostr desktop client",
"main": "./out/main/index.js",
"description": "A user-friendly Nostr client for exploring relay feeds",
"private": true,
"type": "module",
"author": "codytseng",
"license": "MIT",
"repository": {
@@ -11,80 +12,103 @@
},
"homepage": "https://github.com/CodyTseng/jumble",
"scripts": {
"dev": "vite --host",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"dev:web": "vite --config web.vite.config.ts",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win -p never",
"build:mac": "electron-vite build && electron-builder --mac -p never",
"build:linux": "electron-vite build && electron-builder --linux -p never",
"build:web": "vite build --config web.vite.config.ts"
"preview": "vite preview"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@nextui-org/image": "^2.0.32",
"@nextui-org/system": "^2.2.6",
"@nextui-org/theme": "^2.2.11",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"class-variance-authority": "^0.7.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0",
"@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@tailwindcss/typography": "^0.5.16",
"@tiptap/extension-emoji": "^2.26.1",
"@tiptap/extension-history": "^2.12.0",
"@tiptap/extension-mention": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@tiptap/react": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@webbtc/webln-types": "^3.0.0",
"blossom-client-sdk": "^4.1.0",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dataloader": "^2.2.2",
"dataloader": "^2.2.3",
"dayjs": "^1.11.13",
"framer-motion": "^11.11.17",
"i18next": "^23.16.5",
"i18next-browser-languagedetector": "^8.0.0",
"lru-cache": "^11.0.1",
"lucide-react": "^0.453.0",
"nostr-tools": "^2.9.1",
"embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.1.0",
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43",
"franc-min": "^6.2.0",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"jotai": "^2.15.0",
"lru-cache": "^11.0.2",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",
"nostr-tools": "^2.17.0",
"nstart-modal": "^2.0.0",
"path-to-regexp": "^8.2.0",
"qrcode.react": "^4.1.0",
"react-i18next": "^15.1.1",
"react-resizable-panels": "^2.1.5",
"react-string-replace": "^1.1.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"yet-another-react-lightbox": "^3.21.6"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@types/node": "^20.14.8",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"electron": "^31.0.2",
"electron-builder": "^24.13.3",
"electron-vite": "^2.3.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.3",
"prettier": "^3.3.2",
"qr-code-styling": "^1.9.2",
"qr-scanner": "^1.4.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.5.2",
"vite": "^5.3.1"
"react-i18next": "^15.2.0",
"react-markdown": "^10.1.0",
"react-simple-pull-to-refresh": "^1.3.3",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.5",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"thumbhash": "^0.1.1",
"tippy.js": "^6.3.7",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"yet-another-react-lightbox": "^3.21.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/node": "^22.10.2",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/uri-templates": "^0.1.34",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"postcss": "^8.4.49",
"prettier": "3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.1",
"vite": "^6.0.3",
"vite-plugin-pwa": "^0.21.1"
}
}

View File

@@ -1,3 +1,6 @@
module.exports = {
plugins: [require('tailwindcss'), require('autoprefixer')]
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

View File

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

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

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

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

BIN
public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Allow: /

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.6 KiB

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

72
src/App.tsx Normal file
View File

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

534
src/PageManager.tsx Normal file
View File

@@ -0,0 +1,534 @@
import Sidebar from '@/components/Sidebar'
import { cn } from '@/lib/utils'
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
import { TPageRef } from '@/types'
import {
cloneElement,
createContext,
createRef,
ReactNode,
RefObject,
useContext,
useEffect,
useRef,
useState
} from 'react'
import BackgroundAudio from './components/BackgroundAudio'
import BottomNavigationBar from './components/BottomNavigationBar'
import CreateWalletGuideToast from './components/CreateWalletGuideToast'
import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
import { normalizeUrl } from './lib/url'
import { NotificationProvider } from './providers/NotificationProvider'
import { useScreenSize } from './providers/ScreenSizeProvider'
import { useTheme } from './providers/ThemeProvider'
import { useUserPreferences } from './providers/UserPreferencesProvider'
import { PRIMARY_PAGE_MAP, PRIMARY_PAGE_REF_MAP, TPrimaryPageName } from './routes/primary'
import { SECONDARY_ROUTES } from './routes/secondary'
import modalManager from './services/modal-manager.service'
type TPrimaryPageContext = {
navigate: (page: TPrimaryPageName, props?: object) => void
current: TPrimaryPageName | null
display: boolean
}
type TSecondaryPageContext = {
push: (url: string) => void
pop: () => void
currentIndex: number
}
type TStackItem = {
index: number
url: string
element: React.ReactElement | null
ref: RefObject<TPageRef> | null
}
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
export function usePrimaryPage() {
const context = useContext(PrimaryPageContext)
if (!context) {
throw new Error('usePrimaryPage must be used within a PrimaryPageContext.Provider')
}
return context
}
export function useSecondaryPage() {
const context = useContext(SecondaryPageContext)
if (!context) {
throw new Error('usePrimaryPage must be used within a SecondaryPageContext.Provider')
}
return context
}
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
const [primaryPages, setPrimaryPages] = useState<
{ name: TPrimaryPageName; element: ReactNode; props?: any }[]
>([
{
name: 'home',
element: PRIMARY_PAGE_MAP.home
}
])
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const { isSmallScreen } = useScreenSize()
const { themeSetting } = useTheme()
const { enableSingleColumnLayout } = useUserPreferences()
const ignorePopStateRef = useRef(false)
useEffect(() => {
if (isSmallScreen) return
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
navigatePrimaryPage('search')
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [isSmallScreen])
useEffect(() => {
if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) {
window.history.replaceState(
null,
'',
'/users' + window.location.pathname + window.location.search + window.location.hash
)
} else if (
['/note1', '/nevent1', '/naddr1'].some((prefix) =>
window.location.pathname.startsWith(prefix)
)
) {
window.history.replaceState(
null,
'',
'/notes' + window.location.pathname + window.location.search + window.location.hash
)
}
window.history.pushState(null, '', window.location.href)
if (window.location.pathname !== '/') {
const url = window.location.pathname + window.location.search + window.location.hash
setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) return prevStack
const { newStack, newItem } = pushNewPageToStack(
prevStack,
url,
maxStackSize,
window.history.state?.index
)
if (newItem) {
window.history.replaceState({ index: newItem.index, url }, '', url)
}
return newStack
})
} else {
const searchParams = new URLSearchParams(window.location.search)
const r = searchParams.get('r')
if (r) {
const url = normalizeUrl(r)
if (url) {
navigatePrimaryPage('relay', { url })
}
}
}
const onPopState = (e: PopStateEvent) => {
if (ignorePopStateRef.current) {
ignorePopStateRef.current = false
return
}
const closeModal = modalManager.pop()
if (closeModal) {
ignorePopStateRef.current = true
window.history.forward()
return
}
let state = e.state as { index: number; url: string } | null
setSecondaryStack((pre) => {
const currentItem = pre[pre.length - 1] as TStackItem | undefined
const currentIndex = currentItem?.index
if (!state) {
if (window.location.pathname + window.location.search + window.location.hash !== '/') {
// Just change the URL
return pre
} else {
// Back to root
state = { index: -1, url: '/' }
}
}
// Go forward
if (currentIndex === undefined || state.index > currentIndex) {
const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
return newStack
}
if (state.index === currentIndex) {
return pre
}
// Go back
const newStack = pre.filter((item) => item.index <= state!.index)
const topItem = newStack[newStack.length - 1] as TStackItem | undefined
if (!topItem) {
// Create a new stack item if it's not exist (e.g. when the user refreshes the page, the stack will be empty)
const { element, ref } = findAndCloneElement(state.url, state.index)
if (element) {
newStack.push({
index: state.index,
url: state.url,
element,
ref
})
}
} else if (!topItem.element) {
// Load the element if it's not cached
const { element, ref } = findAndCloneElement(topItem.url, state.index)
if (element) {
topItem.element = element
topItem.ref = ref
}
}
if (newStack.length === 0) {
window.history.replaceState(null, '', '/')
}
return newStack
})
}
window.addEventListener('popstate', onPopState)
return () => {
window.removeEventListener('popstate', onPopState)
}
}, [])
const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => {
const needScrollToTop = page === currentPrimaryPage
setPrimaryPages((prev) => {
const exists = prev.find((p) => p.name === page)
if (exists && props) {
exists.props = props
return [...prev]
} else if (!exists) {
return [...prev, { name: page, element: PRIMARY_PAGE_MAP[page], props }]
}
return prev
})
setCurrentPrimaryPage(page)
if (needScrollToTop) {
PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth')
}
if (enableSingleColumnLayout) {
clearSecondaryPages()
}
}
const pushSecondaryPage = (url: string, index?: number) => {
setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) {
const currentItem = prevStack[prevStack.length - 1]
if (currentItem?.ref?.current) {
currentItem.ref.current.scrollToTop('instant')
}
return prevStack
}
const { newStack, newItem } = pushNewPageToStack(prevStack, url, maxStackSize, index)
if (newItem) {
window.history.pushState({ index: newItem.index, url }, '', url)
}
return newStack
})
}
const popSecondaryPage = (delta = -1) => {
if (secondaryStack.length <= -delta) {
// back to home page
window.history.replaceState(null, '', '/')
setSecondaryStack([])
} else {
window.history.go(delta)
}
}
const clearSecondaryPages = () => {
if (secondaryStack.length === 0) return
popSecondaryPage(-secondaryStack.length)
}
if (isSmallScreen) {
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: secondaryStack.length === 0
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.element}
</div>
))}
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
style={{
display:
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
<BottomNavigationBar />
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
if (enableSingleColumnLayout) {
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: secondaryStack.length === 0
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<div className="flex lg:justify-around w-full">
<div className="sticky top-0 lg:w-full flex justify-end self-start h-[var(--vh)]">
<Sidebar />
</div>
<div className="flex-1 w-0 bg-background border-x lg:flex-auto lg:w-[640px] lg:shrink-0">
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.element}
</div>
))}
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
style={{
display:
secondaryStack.length === 0 && currentPrimaryPage === name
? 'block'
: 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
</div>
<div className="hidden lg:w-full lg:block" />
</div>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: true
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<div className="flex flex-col items-center bg-surface-background">
<div
className="flex h-[var(--vh)] w-full bg-surface-background"
style={{
maxWidth: '1920px'
}}
>
<Sidebar />
<div
className={cn(
'grid grid-cols-2 w-full',
themeSetting === 'pure-black' ? '' : 'gap-2 pr-2 py-2'
)}
>
<div
className={cn(
'bg-background overflow-hidden',
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl shadow-lg'
)}
>
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
className="flex flex-col h-full w-full"
style={{
display: currentPrimaryPage === name ? 'block' : 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
</div>
<div
className={cn(
'bg-background overflow-hidden',
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl',
themeSetting !== 'pure-black' && secondaryStack.length > 0 && 'shadow-lg',
secondaryStack.length === 0 ? 'bg-surface' : ''
)}
>
{secondaryStack.map((item, index) => (
<div
key={item.index}
className="flex flex-col h-full w-full"
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
>
{item.element}
</div>
))}
</div>
</div>
</div>
</div>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
export function SecondaryPageLink({
to,
children,
className,
onClick
}: {
to: string
children: React.ReactNode
className?: string
onClick?: (e: React.MouseEvent) => void
}) {
const { push } = useSecondaryPage()
return (
<span
className={cn('cursor-pointer', className)}
onClick={(e) => {
if (onClick) {
onClick(e)
}
push(to)
}}
>
{children}
</span>
)
}
function isCurrentPage(stack: TStackItem[], url: string) {
const currentPage = stack[stack.length - 1]
if (!currentPage) return false
return currentPage.url === url
}
function findAndCloneElement(url: string, index: number) {
const path = url.split('?')[0].split('#')[0]
for (const { matcher, element } of SECONDARY_ROUTES) {
const match = matcher(path)
if (!match) continue
if (!element) return {}
const ref = createRef<TPageRef>()
return { element: cloneElement(element, { ...match.params, index, ref } as any), ref }
}
return {}
}
function pushNewPageToStack(
stack: TStackItem[],
url: string,
maxStackSize = 5,
specificIndex?: number
) {
const currentItem = stack[stack.length - 1]
const currentIndex = specificIndex ?? (currentItem ? currentItem.index + 1 : 0)
const { element, ref } = findAndCloneElement(url, currentIndex)
if (!element) return { newStack: stack, newItem: null }
const newItem = { element, ref, url, index: currentIndex }
const newStack = [...stack, newItem]
const lastCachedIndex = newStack.findIndex((stack) => stack.element)
// Clear the oldest cached element if there are too many cached elements
if (newStack.length - lastCachedIndex > maxStackSize) {
newStack[lastCachedIndex].element = null
}
return { newStack, newItem }
}

24
src/assets/Icon.tsx Normal file
View File

@@ -0,0 +1,24 @@
export default function Icon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 1080 1228"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve"
style={{
fill: 'currentcolor',
fillRule: 'evenodd',
clipRule: 'evenodd',
strokeLinejoin: 'round',
strokeMiterlimit: 2
}}
className={className}
>
<path
id="Icon-Curve-Cut"
d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z"
/>
</svg>
)
}

File diff suppressed because one or more lines are too long

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

@@ -1,5 +0,0 @@
export const StorageKey = {
THEME_SETTING: 'themeSetting',
RELAY_GROUPS: 'relayGroups',
ACCOUNT: 'account'
}

View File

@@ -1,56 +0,0 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Event } from 'nostr-tools'
export type TRelayGroup = {
groupName: string
relayUrls: string[]
isActive: boolean
}
export type TConfig = {
relayGroups: TRelayGroup[]
theme: TThemeSetting
}
export type TThemeSetting = 'light' | 'dark' | 'system'
export type TTheme = 'light' | 'dark'
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>
export interface ISigner {
getPublicKey: () => Promise<string | null>
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
}
export type TElectronWindow = {
electron: ElectronAPI
api: {
system: {
isEncryptionAvailable: () => Promise<boolean>
getSelectedStorageBackend: () => Promise<string>
}
theme: {
addChangeListener: (listener: (theme: TTheme) => void) => void
removeChangeListener: () => void
current: () => Promise<TTheme>
}
storage: {
getItem: (key: string) => Promise<string>
setItem: (key: string, value: string) => Promise<void>
removeItem: (key: string) => Promise<void>
}
nostr: {
login: (nsec: string) => Promise<{
pubkey?: string
reason?: string
}>
logout: () => Promise<void>
}
}
nostr: ISigner
}
export type TAccount = {
signerType: 'nsec' | 'browser-nsec' | 'nip-07'
nsec?: string
}

View File

@@ -0,0 +1,55 @@
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import { CODY_PUBKEY } from '@/constants'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useState } from 'react'
import Username from '../Username'
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
const content = (
<>
<div className="text-xl font-semibold">Jumble</div>
<div className="text-muted-foreground">
A user-friendly Nostr client for exploring relay feeds
</div>
<div>
Made by <Username userId={CODY_PUBKEY} className="inline-block text-primary" showAt />
</div>
<div>
Source code:{' '}
<a
href="https://github.com/CodyTseng/jumble"
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
GitHub
</a>
<div className="text-sm text-muted-foreground">
If you like Jumble, please consider giving it a star
</div>
</div>
</>
)
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<div className="p-4 space-y-4">{content}</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>{content}</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,76 @@
import { Button } from '@/components/ui/button'
import { isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { TAccountPointer } from '@/types'
import { Loader, Trash2 } from 'lucide-react'
import { useState } from 'react'
import SignerTypeBadge from '../SignerTypeBadge'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
export default function AccountList({
className,
afterSwitch
}: {
className?: string
afterSwitch: () => void
}) {
const { accounts, account, switchAccount, removeAccount } = useNostr()
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
return (
<div className={cn('space-y-2', className)}>
{accounts.map((act) => (
<div
key={`${act.pubkey}-${act.signerType}`}
className={cn(
'relative rounded-lg',
isSameAccount(act, account) ? 'border border-primary' : 'clickable'
)}
onClick={() => {
if (isSameAccount(act, account)) return
setSwitchingAccount(act)
switchAccount(act)
.then(() => afterSwitch())
.finally(() => setSwitchingAccount(null))
}}
>
<div className="flex justify-between items-center p-2">
<div className="flex-1 flex items-center gap-2 relative">
<SimpleUserAvatar userId={act.pubkey} />
<div className="flex-1 w-0">
<SimpleUsername userId={act.pubkey} className="font-semibold truncate" />
<div className="text-sm rounded-full bg-muted px-2 w-fit">
{formatPubkey(act.pubkey)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<SignerTypeBadge signerType={act.signerType} />
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
removeAccount(act)
}}
>
<Trash2 />
</Button>
</div>
</div>
{switchingAccount && isSameAccount(act, switchingAccount) && (
<div className="absolute top-0 left-0 flex w-full h-full items-center justify-center rounded-lg bg-muted/60">
<Loader size={16} className="animate-spin" />
</div>
)}
</div>
))}
</div>
)
}

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 BunkerLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { bunkerLogin } = useNostr()
const [pending, setPending] = useState(false)
const [bunkerInput, setBunkerInput] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBunkerInput(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (bunkerInput === '') return
setPending(true)
bunkerLogin(bunkerInput)
.then(() => onLoginSuccess())
.catch((err) => setErrMsg(err.message))
.finally(() => setPending(false))
}
return (
<>
<div className="space-y-1">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div>
<Button onClick={handleLogin} disabled={pending}>
<Loader className={pending ? 'animate-spin' : 'hidden'} />
{t('Login')}
</Button>
<Button variant="secondary" onClick={back}>
{t('Back')}
</Button>
</>
)
}

View File

@@ -0,0 +1,85 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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 [password, setPassword] = useState('')
const handleLogin = () => {
nsecLogin(nsec, password, true).then(() => onLoginSuccess())
}
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
handleLogin()
}}
>
<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="grid gap-2">
<Label>nsec</Label>
<div className="flex gap-2">
<Input value={nsec} />
<Button type="button" variant="secondary" onClick={() => setNsec(generateNsec())}>
<RefreshCcw />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="password-input">{t('password')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('optional: encrypt nsec')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
{t('Back')}
</Button>
<Button className="flex-1" type="submit">
{t('Login')}
</Button>
</div>
</form>
)
}
function 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

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

View File

@@ -0,0 +1,125 @@
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 GenerateNewAccount from './GenerateNewAccount'
import NostrConnectLogin from './NostrConnectionLogin'
import NpubLogin from './NpubLogin'
import PrivateKeyLogin from './PrivateKeyLogin'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null
export default function AccountManager({ close }: { close?: () => void }) {
const [page, setPage] = useState<TAccountManagerPage>(null)
return (
<>
{page === 'nsec' ? (
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'bunker' ? (
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'generate' ? (
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'npub' ? (
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : (
<AccountManagerNav setPage={setPage} close={close} />
)}
</>
)
}
function AccountManagerNav({
setPage,
close
}: {
setPage: (page: TAccountManagerPage) => void
close?: () => void
}) {
const { t, i18n } = useTranslation()
const { themeSetting } = useTheme()
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
return (
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
<div>
<div className="text-center text-muted-foreground text-sm font-semibold">
{t('Add an Account')}
</div>
<div className="space-y-2 mt-4">
{!!window.nostr && (
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
{t('Login with Browser Extension')}
</Button>
)}
<Button variant="secondary" onClick={() => setPage('bunker')} className="w-full">
{t('Login with Bunker')}
</Button>
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
{t('Login with Private Key')}
</Button>
{isDevEnv() && (
<Button variant="secondary" onClick={() => setPage('npub')} className="w-full">
Login with Public key (for development)
</Button>
)}
</div>
</div>
<Separator />
<div>
<div className="text-center text-muted-foreground text-sm font-semibold">
{t("Don't have an account yet?")}
</div>
<Button
onClick={() => {
const wizard = new NstartModal({
baseUrl: 'https://nstart.me',
an: 'Jumble',
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
al: i18n.language.slice(0, 2),
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>
</div>
{accounts.length > 0 && (
<>
<Separator />
<div>
<div className="text-center text-muted-foreground text-sm font-semibold">
{t('Logged in Accounts')}
</div>
<AccountList className="mt-4" afterSwitch={() => close?.()} />
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { TriangleAlert } from 'lucide-react'
export default function AlertCard({ title, content }: { title: string; content: string }) {
return (
<div className="p-3 rounded-lg text-sm bg-amber-100/20 dark:bg-amber-950/20 border border-amber-500 text-amber-500 [&_svg]:size-4">
<div className="flex items-center gap-2">
<TriangleAlert />
<div className="font-medium">{title}</div>
</div>
<div className="pl-6">{content}</div>
</div>
)
}

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

@@ -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

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

View File

@@ -0,0 +1,32 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { MouseEventHandler } from 'react'
export default function BottomNavigationBarItem({
children,
active = false,
onClick,
onPointerDown,
onPointerUp
}: {
children: React.ReactNode
active?: boolean
onClick?: MouseEventHandler
onPointerDown?: MouseEventHandler
onPointerUp?: MouseEventHandler
}) {
return (
<Button
className={cn(
'flex shadow-none items-center bg-transparent w-full h-12 p-3 m-0 rounded-lg [&_svg]:size-6',
active && 'text-primary hover:text-primary'
)}
variant="ghost"
onClick={onClick}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
>
{children}
</Button>
)
}

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,175 @@
import { useTranslatedEvent } from '@/hooks'
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedHashtagParser,
EmbeddedLNInvoiceParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import mediaUpload from '@/services/media-upload.service'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import {
EmbeddedHashtag,
EmbeddedLNInvoice,
EmbeddedMention,
EmbeddedNote,
EmbeddedWebsocketUrl
} from '../Embedded'
import Emoji from '../Emoji'
import ExternalLink from '../ExternalLink'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview'
import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
export default function Content({
event,
content,
className,
mustLoadMedia
}: {
event?: Event
content?: string
className?: string
mustLoadMedia?: boolean
}) {
const translatedEvent = useTranslatedEvent(event?.id)
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
const _content = translatedEvent?.content ?? event?.content ?? content
if (!_content) return {}
const nodes = parseContent(_content, [
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedLNInvoiceParser,
EmbeddedWebsocketUrlParser,
EmbeddedHashtagParser,
EmbeddedEmojiParser
])
const imetaInfos = event ? getImetaInfosFromEvent(event) : []
const allImages = nodes
.map((node) => {
if (node.type === 'image') {
const imageInfo = imetaInfos.find((image) => image.url === node.data)
if (imageInfo) {
return imageInfo
}
const tag = mediaUpload.getImetaTagByUrl(node.data)
return tag
? getImetaInfoFromImetaTag(tag, event?.pubkey)
: { url: node.data, pubkey: event?.pubkey }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imetaInfos.find((image) => image.url === url)
return imageInfo ?? { url, pubkey: event?.pubkey }
})
}
return null
})
.filter(Boolean)
.flat() as TImetaInfo[]
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content])
if (!nodes || nodes.length === 0) {
return null
}
let imageIndex = 0
return (
<div 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>
)
}

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

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

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

@@ -0,0 +1,48 @@
import { formatAmount } from '@/lib/lightning'
import lightning, { TRecentSupporter } from '@/services/lightning.service'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
export default function RecentSupporters() {
const { t } = useTranslation()
const [supporters, setSupporters] = useState<TRecentSupporter[]>([])
useEffect(() => {
const init = async () => {
const items = await lightning.fetchRecentSupporters()
setSupporters(items)
}
init()
}, [])
if (!supporters.length) return null
return (
<div className="space-y-2">
<div className="font-semibold text-center">{t('Recent Supporters')}</div>
<div className="flex flex-col gap-2">
{supporters.map((item, index) => (
<div
key={index}
className="flex items-center justify-between rounded-md border p-2 sm:p-4 gap-2"
>
<div className="flex items-center gap-2 flex-1 w-0">
<UserAvatar userId={item.pubkey} />
<div className="flex-1 w-0">
<Username className="font-semibold w-fit" userId={item.pubkey} />
<div className="text-xs text-muted-foreground line-clamp-3 select-text">
{item.comment}
</div>
</div>
</div>
<div className="font-semibold text-yellow-400 shrink-0">
{formatAmount(item.amount)} {t('sats')}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { Button } from '@/components/ui/button'
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 }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [donationAmount, setDonationAmount] = useState<number | undefined>(undefined)
return (
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
<div className="text-center font-semibold">{t('Enjoying Jumble?')}</div>
<div className="text-center text-muted-foreground">
{t('Your donation helps me maintain Jumble and make it better! 😊')}
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ amount: 1000, text: '☕️ 1k' },
{ amount: 10000, text: '🍜 10k' },
{ amount: 100000, text: '🍣 100k' },
{ amount: 1000000, text: '✈️ 1M' }
].map(({ amount, text }) => {
return (
<Button
variant="secondary"
className=""
key={amount}
onClick={() => {
setDonationAmount(amount)
setOpen(true)
}}
>
{text}
</Button>
)
})}
</div>
<PlatinumSponsors />
<RecentSupporters />
<ZapDialog
open={open}
setOpen={setOpen}
pubkey={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

@@ -0,0 +1,14 @@
import { toNoteList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
return (
<SecondaryPageLink
className="text-primary hover:underline"
to={toNoteList({ hashtag: hashtag.replace('#', '') })}
onClick={(e) => e.stopPropagation()}
>
{hashtag}
</SecondaryPageLink>
)
}

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

@@ -0,0 +1,19 @@
import { cn } from '@/lib/utils'
import Username, { SimpleUsername } from '../Username'
export function EmbeddedMention({ userId, className }: { userId: string; className?: string }) {
return (
<Username
userId={userId}
showAt
className={cn('text-primary font-normal inline', className)}
withoutSkeleton
/>
)
}
export function EmbeddedMentionText({ userId, className }: { userId: string; className?: string }) {
return (
<SimpleUsername userId={userId} showAt className={cn('inline', className)} withoutSkeleton />
)
}

View File

@@ -0,0 +1,59 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent } from '@/hooks'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect'
import MainNoteCard from '../NoteCard/MainNoteCard'
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
const { event, isFetching } = useFetchEvent(noteId)
if (isFetching) {
return <EmbeddedNoteSkeleton className={className} />
}
if (!event) {
return <EmbeddedNoteNotFound className={className} noteId={noteId} />
}
return (
<MainNoteCard
className={cn('w-full', className)}
event={event}
embedded
originalNoteId={noteId}
/>
)
}
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
return (
<div
className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-2">
<Skeleton className="w-9 h-9 rounded-full" />
<div>
<Skeleton className="h-3 w-16 my-1" />
<Skeleton className="h-3 w-16 my-1" />
</div>
</div>
<Skeleton className="w-full h-4 my-1 mt-2" />
<Skeleton className="w-2/3 h-4 my-1" />
</div>
)
}
function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) {
const { t } = useTranslation()
return (
<div className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}>
<div className="flex flex-col items-center text-muted-foreground font-medium gap-2">
<div>{t('Sorry! The note cannot be found 😔')}</div>
<ClientSelect className="w-full mt-2" originalNoteId={noteId} />
</div>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { useSecondaryPage } from '@/PageManager'
import { toRelay } from '@/lib/link'
export function EmbeddedWebsocketUrl({ url }: { url: string }) {
const { push } = useSecondaryPage()
return (
<span
className="cursor-pointer px-1 text-primary hover:bg-primary/20"
onClick={(e) => {
e.stopPropagation()
push(toRelay(url))
}}
>
[ {url} ]
<span className="w-2 h-1 bg-primary" />
</span>
)
}

View File

@@ -0,0 +1,5 @@
export * from './EmbeddedHashtag'
export * from './EmbeddedLNInvoice'
export * from './EmbeddedMention'
export * from './EmbeddedNote'
export * from './EmbeddedWebsocketUrl'

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" />
}

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