11 Commits

Author SHA1 Message Date
woikos
ecd7c36400 Add NRC (Nostr Relay Connect) for cross-device sync
Implements NRC listener that allows other user clients to connect
and sync events through a rendezvous relay. Features:
- REQ-only (read) sync for security
- Secret-based and CAT token authentication
- NIP-44 encrypted tunneling
- Device-specific event filtering via d-tag prefix
- Session management with timeouts
- Settings UI with QR code connection flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:16:03 +01:00
woikos
08f75a902d Release v0.4.1
Auto-search after QR scan in search bar.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:38:28 +01:00
woikos
8a9795a53a Add graph query optimization for faster social graph operations
- Add GraphQueryService for NIP-XX graph queries
- Add GraphCacheService for IndexedDB caching of results
- Optimize FollowedBy component with graph queries
- Add graph query support to ThreadService
- Add useFetchFollowGraph hook
- Add graph query toggle in Settings > System
- Bump version to v0.4.0

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-05 14:37:36 +01:00
woikos
d1ec24b85a Add keyboard mode toggle and QR scanner improvements
- Add keyboard mode toggle button (⇧K) in sidebar
- Triple-Escape to quickly exit keyboard mode
- Extract QrScannerModal to shared component
- Add QR scanner for NWC wallet connection in Settings
- Update Help page with keyboard toggle documentation
- Fix keyboard navigation getting stuck on inbox
- Improve feed loading after login (loads immediately)
- DM conversation page layout improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:08:06 +01:00
woikos
4c3e8d5cc7 Release v0.3.1
- Feed bounded context with DDD implementation (Phases 1-5)
- Domain event handlers for cross-context coordination
- Fix Blossom media upload setting persistence
- Fix wallet connection persistence on page reload
- New branding assets and icons
- Vitest testing infrastructure with 151 domain model tests
- Help page scaffolding
- Keyboard navigation provider

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 07:29:07 +01:00
woikos
158f3d77d3 Bump version to v0.3.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 10:55:53 +01:00
woikos
f54c73f0eb Update og:image to use smesh branding
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:11:14 +01:00
woikos
1d58162890 Release v0.2.5
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:07:38 +01:00
woikos
9820a1c6c0 Move DM conversations to secondary panel with improvements
- Open DM conversations in secondary panel (dual-pane) or overlay (mobile)
- Add relay selection toggles in message composer
- Add background DM subscriptions for real-time updates
- Add close buttons on conversation items and message view header
- Replace conversation view instead of stacking when switching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:04:30 +01:00
woikos
ad5f9cccf9 Fix refresh to merge data instead of wiping cache
- Remove clearAllDMCaches() call from manual refresh
- Merge new conversations with existing instead of replacing
- Cache clearing now only happens on logout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 13:35:29 +01:00
woikos
2e3b854037 DM inbox caching and linkification improvements (v0.2.4)
- Add MessageContent component for linkified DM messages
- URLs open in new tab, nostr: entities open in secondary pane
- Implement background refresh that merges instead of clearing cache
- Show cached conversations immediately on page load
- Fix navigation not reloading conversations unnecessarily

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 13:26:17 +01:00
207 changed files with 18716 additions and 2297 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"additionalDirectories": [
"/home/mleku/src/git.mleku.dev/mleku/coracle"
]
}
}

File diff suppressed because it is too large Load Diff

581
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "smesh",
"version": "0.2.1",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smesh",
"version": "0.2.1",
"version": "0.3.0",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -91,6 +91,7 @@
"@types/react-dom": "^18.3.5",
"@types/uri-templates": "^0.1.34",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.0.16",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
@@ -102,7 +103,8 @@
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.1",
"vite": "^6.0.3",
"vite-plugin-pwa": "^0.21.1"
"vite-plugin-pwa": "^0.21.1",
"vitest": "^4.0.16"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1599,6 +1601,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@cashu/cashu-ts": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.5.2.tgz",
@@ -2555,14 +2567,16 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@@ -5301,6 +5315,13 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -5804,6 +5825,17 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -5812,6 +5844,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -6181,6 +6220,132 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz",
"integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.0.16",
"ast-v8-to-istanbul": "^0.3.8",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
"std-env": "^3.10.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.0.16",
"vitest": "4.0.16"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.16",
"@vitest/utils": "4.0.16",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz",
"integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz",
"integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.16",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz",
"integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.16",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz",
"integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz",
"integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.16",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webbtc/webln-types": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@webbtc/webln-types/-/webln-types-3.0.0.tgz",
@@ -6330,6 +6495,45 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
"integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^9.0.1"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -6658,9 +6862,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001690",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz",
"integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==",
"version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"devOptional": true,
"funding": [
{
@@ -6675,7 +6879,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
],
"license": "CC-BY-4.0"
},
"node_modules/ccount": {
"version": "2.0.1",
@@ -6686,6 +6891,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -7799,6 +8014,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
@@ -8091,6 +8313,16 @@
"node": ">=0.10.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8676,6 +8908,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -9293,6 +9532,60 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -9598,6 +9891,47 @@
"sourcemap-codec": "^1.4.8"
}
},
"node_modules/magicast": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz",
"integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
@@ -10663,6 +10997,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -10832,6 +11177,13 @@
"node": ">=16"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -12019,6 +12371,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -12101,6 +12460,20 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -12510,6 +12883,23 @@
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -12558,6 +12948,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tippy.js": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
@@ -13227,6 +13627,144 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz",
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
"@vitest/pretty-format": "4.0.16",
"@vitest/runner": "4.0.16",
"@vitest/snapshot": "4.0.16",
"@vitest/spy": "4.0.16",
"@vitest/utils": "4.0.16",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.16",
"@vitest/browser-preview": "4.0.16",
"@vitest/browser-webdriverio": "4.0.16",
"@vitest/ui": "4.0.16",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz",
"integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.16",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/vitest/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -13370,6 +13908,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "smesh",
"version": "0.2.2",
"version": "0.4.1",
"description": "A user-friendly Nostr client for exploring relay feeds",
"private": true,
"type": "module",
@@ -17,7 +17,10 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -102,6 +105,7 @@
"@types/react-dom": "^18.3.5",
"@types/uri-templates": "^0.1.34",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.0.16",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
@@ -113,6 +117,7 @@
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.1",
"vite": "^6.0.3",
"vite-plugin-pwa": "^0.21.1"
"vite-plugin-pwa": "^0.21.1",
"vitest": "^4.0.16"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 513 B

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="200"
height="200">
<!-- Dark background -->
<rect x="0" y="0" width="100" height="100" fill="#171717" />
<!-- Grid icon (white strokes) -->
<g fill="none" stroke="white" stroke-width="12" stroke-linecap="round">
<!-- Vertical lines -->
<line x1="25" y1="10" x2="25" y2="90" />
<line x1="50" y1="10" x2="50" y2="90" />
<line x1="75" y1="10" x2="75" y2="90" />
<!-- Horizontal lines -->
<line x1="10" y1="25" x2="90" y2="25" />
<line x1="10" y1="50" x2="90" y2="50" />
<line x1="10" y1="75" x2="90" y2="75" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 650 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 513 B

20
resources/icon-white.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="200"
height="200"
fill="none"
stroke="white"
stroke-width="12"
stroke-linecap="round">
<!-- Vertical lines -->
<line x1="25" y1="10" x2="25" y2="90" />
<line x1="50" y1="10" x2="50" y2="90" />
<line x1="75" y1="10" x2="75" y2="90" />
<!-- Horizontal lines -->
<line x1="10" y1="25" x2="90" y2="25" />
<line x1="10" y1="50" x2="90" y2="50" />
<line x1="10" y1="75" x2="90" y2="75" />
</svg>

After

Width:  |  Height:  |  Size: 513 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 513 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -4,6 +4,7 @@ import './index.css'
import { Toaster } from '@/components/ui/sonner'
import { BookmarksProvider } from '@/providers/BookmarksProvider'
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
import { EventHandlerProvider } from '@/providers/EventHandlerProvider'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { DMProvider } from '@/providers/DMProvider'
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
@@ -11,12 +12,15 @@ import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider'
import { FollowListProvider } from '@/providers/FollowListProvider'
import { KindFilterProvider } from '@/providers/KindFilterProvider'
import { SocialGraphFilterProvider } from '@/providers/SocialGraphFilterProvider'
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
import { MuteListProvider } from '@/providers/MuteListProvider'
import { NostrProvider } from '@/providers/NostrProvider'
import { NRCProvider } from '@/providers/NRCProvider'
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
import { PinListProvider } from '@/providers/PinListProvider'
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
import { RepositoryProvider } from '@/providers/RepositoryProvider'
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
import { SettingsSyncProvider } from '@/providers/SettingsSyncProvider'
import { ThemeProvider } from '@/providers/ThemeProvider'
@@ -28,12 +32,15 @@ import { PageManager } from './PageManager'
export default function App(): JSX.Element {
return (
<ScreenSizeProvider>
<EventHandlerProvider>
<UserPreferencesProvider>
<ThemeProvider>
<ContentPolicyProvider>
<DeletedEventProvider>
<PasswordPromptProvider>
<NostrProvider>
<NRCProvider>
<RepositoryProvider>
<SettingsSyncProvider>
<ZapProvider>
<FavoriteRelaysProvider>
@@ -47,10 +54,12 @@ export default function App(): JSX.Element {
<PinnedUsersProvider>
<FeedProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
<SocialGraphFilterProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</SocialGraphFilterProvider>
</MediaUploadServiceProvider>
</FeedProvider>
</PinnedUsersProvider>
@@ -64,12 +73,15 @@ export default function App(): JSX.Element {
</FavoriteRelaysProvider>
</ZapProvider>
</SettingsSyncProvider>
</RepositoryProvider>
</NRCProvider>
</NostrProvider>
</PasswordPromptProvider>
</DeletedEventProvider>
</ContentPolicyProvider>
</ThemeProvider>
</UserPreferencesProvider>
</EventHandlerProvider>
</ScreenSizeProvider>
)
}

View File

@@ -1,7 +1,9 @@
import ActionModeOverlay from '@/components/ActionModeOverlay'
import Sidebar from '@/components/Sidebar'
import SidebarDrawer from '@/components/SidebarDrawer'
import { cn } from '@/lib/utils'
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
import { KeyboardNavigationProvider } from '@/providers/KeyboardNavigationProvider'
import { TPageRef } from '@/types'
import {
cloneElement,
@@ -321,34 +323,42 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<SidebarDrawerContext.Provider value={sidebarDrawerContext}>
<CurrentRelaysProvider>
<NotificationProvider>
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<KeyboardNavigationProvider
secondaryStackLength={secondaryStack.length}
sidebarDrawerOpen={sidebarDrawerOpen}
onBack={() => popSecondaryPage()}
onCloseSecondary={() => clearSecondaryPages()}
>
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.element}
</div>
))}
{primaryPages.map(({ name, element, props }) => (
<div
key={item.index}
key={name}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
display:
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
}}
>
{item.element}
{props ? cloneElement(element as React.ReactElement, props) : 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>
))}
<SidebarDrawer
open={sidebarDrawerOpen}
onOpenChange={setSidebarDrawerOpen}
/>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<SidebarDrawer
open={sidebarDrawerOpen}
onOpenChange={setSidebarDrawerOpen}
/>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<ActionModeOverlay />
</KeyboardNavigationProvider>
</NotificationProvider>
</CurrentRelaysProvider>
</SidebarDrawerContext.Provider>
@@ -377,41 +387,49 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
>
<CurrentRelaysProvider>
<NotificationProvider>
<div className="flex lg:justify-around w-full bg-chrome-background">
<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) => (
<KeyboardNavigationProvider
secondaryStackLength={secondaryStack.length}
sidebarDrawerOpen={false}
onBack={() => popSecondaryPage()}
onCloseSecondary={() => clearSecondaryPages()}
>
<div className="flex lg:justify-around w-full bg-chrome-background">
<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={item.index}
key={name}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
display:
secondaryStack.length === 0 && currentPrimaryPage === name
? 'block'
: 'none'
}}
>
{item.element}
{props ? cloneElement(element as React.ReactElement, props) : 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>
<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" />
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
<ActionModeOverlay />
</KeyboardNavigationProvider>
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
@@ -436,62 +454,70 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
>
<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 />
<KeyboardNavigationProvider
secondaryStackLength={secondaryStack.length}
sidebarDrawerOpen={false}
onBack={() => popSecondaryPage()}
onCloseSecondary={() => clearSecondaryPages()}
>
<div className="flex flex-col items-center bg-surface-background">
<div
className={cn(
'grid grid-cols-2 w-full',
themeSetting === 'pure-black' ? '' : 'gap-2 pr-2 py-2'
)}
className="flex h-[var(--vh)] w-full bg-surface-background"
style={{
maxWidth: '1920px'
}}
>
<Sidebar />
<div
className={cn(
'bg-background overflow-hidden',
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl shadow-lg'
'grid grid-cols-2 w-full',
themeSetting === 'pure-black' ? '' : 'gap-2 pr-2 py-2'
)}
>
{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
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>
</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" />
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
<ActionModeOverlay />
</KeyboardNavigationProvider>
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>

View File

@@ -0,0 +1,257 @@
import {
EventBookmarked,
EventUnbookmarked,
BookmarkListPublished,
NotePinned,
NoteUnpinned,
PinsLimitExceeded,
PinListPublished,
ReactionAdded,
ContentReposted
} from '@/domain/content'
import { EventHandler, eventDispatcher } from '@/domain/shared'
/**
* Handlers for content domain events
*
* These handlers coordinate cross-context updates when content events occur.
* They enable real-time UI updates and cross-context coordination.
*/
/**
* Callback for updating reaction counts in UI
*/
export type UpdateReactionCountCallback = (eventId: string, emoji: string, delta: number) => void
/**
* Callback for updating repost counts in UI
*/
export type UpdateRepostCountCallback = (eventId: string, delta: number) => void
/**
* Callback for creating notifications
*/
export type CreateNotificationCallback = (
type: 'reaction' | 'repost' | 'mention' | 'reply',
actorPubkey: string,
targetEventId: string
) => void
/**
* Callback for showing toast messages
*/
export type ShowToastCallback = (message: string, type: 'info' | 'warning' | 'error') => void
/**
* Callback for updating profile pinned notes
*/
export type UpdateProfilePinsCallback = (pubkey: string) => void
/**
* Service callbacks for cross-context coordination
*/
export interface ContentHandlerCallbacks {
onUpdateReactionCount?: UpdateReactionCountCallback
onUpdateRepostCount?: UpdateRepostCountCallback
onCreateNotification?: CreateNotificationCallback
onShowToast?: ShowToastCallback
onUpdateProfilePins?: UpdateProfilePinsCallback
}
let callbacks: ContentHandlerCallbacks = {}
/**
* Set the callbacks for cross-context coordination
* Call this during provider initialization
*/
export function setContentHandlerCallbacks(newCallbacks: ContentHandlerCallbacks): void {
callbacks = { ...callbacks, ...newCallbacks }
}
/**
* Clear all callbacks (for cleanup/testing)
*/
export function clearContentHandlerCallbacks(): void {
callbacks = {}
}
/**
* Handler for event bookmarked
* Can be used to:
* - Update bookmark count displays
* - Prefetch bookmarked content for offline access
*/
export const handleEventBookmarked: EventHandler<EventBookmarked> = async (event) => {
console.debug('[ContentEventHandler] Event bookmarked:', {
actor: event.actor.formatted,
bookmarkedEventId: event.bookmarkedEventId,
type: event.bookmarkType
})
// Future: Trigger prefetch of bookmarked content
}
/**
* Handler for event unbookmarked
*/
export const handleEventUnbookmarked: EventHandler<EventUnbookmarked> = async (event) => {
console.debug('[ContentEventHandler] Event unbookmarked:', {
actor: event.actor.formatted,
unbookmarkedEventId: event.unbookmarkedEventId
})
}
/**
* Handler for bookmark list published
*/
export const handleBookmarkListPublished: EventHandler<BookmarkListPublished> = async (event) => {
console.debug('[ContentEventHandler] Bookmark list published:', {
owner: event.owner.formatted,
bookmarkCount: event.bookmarkCount
})
}
/**
* Handler for note pinned
* Coordinates with:
* - Profile context: Update pinned notes display
* - Cache context: Ensure pinned content is cached
*/
export const handleNotePinned: EventHandler<NotePinned> = async (event) => {
console.debug('[ContentEventHandler] Note pinned:', {
actor: event.actor.formatted,
pinnedEventId: event.pinnedEventId.hex
})
// Update profile display to show new pinned note
if (callbacks.onUpdateProfilePins) {
callbacks.onUpdateProfilePins(event.actor.hex)
}
}
/**
* Handler for note unpinned
* Coordinates with:
* - Profile context: Update pinned notes display
*/
export const handleNoteUnpinned: EventHandler<NoteUnpinned> = async (event) => {
console.debug('[ContentEventHandler] Note unpinned:', {
actor: event.actor.formatted,
unpinnedEventId: event.unpinnedEventId
})
// Update profile display to remove unpinned note
if (callbacks.onUpdateProfilePins) {
callbacks.onUpdateProfilePins(event.actor.hex)
}
}
/**
* Handler for pins limit exceeded
* Coordinates with:
* - UI context: Show toast notification about removed pins
*/
export const handlePinsLimitExceeded: EventHandler<PinsLimitExceeded> = async (event) => {
console.debug('[ContentEventHandler] Pins limit exceeded:', {
actor: event.actor.formatted,
removedCount: event.removedEventIds.length
})
// Show toast notification about removed pins
if (callbacks.onShowToast) {
callbacks.onShowToast(
`Pin limit reached. ${event.removedEventIds.length} older pin(s) were removed.`,
'warning'
)
}
}
/**
* Handler for pin list published
*/
export const handlePinListPublished: EventHandler<PinListPublished> = async (event) => {
console.debug('[ContentEventHandler] Pin list published:', {
owner: event.owner.formatted,
pinCount: event.pinCount
})
}
/**
* Handler for reaction added
* Coordinates with:
* - UI context: Update reaction counts in real-time
* - Notification context: Create notification for content author
*/
export const handleReactionAdded: EventHandler<ReactionAdded> = async (event) => {
console.debug('[ContentEventHandler] Reaction added:', {
actor: event.actor.formatted,
targetEventId: event.targetEventId.hex,
targetAuthor: event.targetAuthor.formatted,
emoji: event.emoji,
isLike: event.isLike
})
// Update reaction count in UI
if (callbacks.onUpdateReactionCount) {
callbacks.onUpdateReactionCount(event.targetEventId.hex, event.emoji, 1)
}
// Create notification for the content author (if not self)
if (callbacks.onCreateNotification && event.actor.hex !== event.targetAuthor.hex) {
callbacks.onCreateNotification('reaction', event.actor.hex, event.targetEventId.hex)
}
}
/**
* Handler for content reposted
* Coordinates with:
* - UI context: Update repost counts in real-time
* - Notification context: Create notification for original author
*/
export const handleContentReposted: EventHandler<ContentReposted> = async (event) => {
console.debug('[ContentEventHandler] Content reposted:', {
actor: event.actor.formatted,
originalEventId: event.originalEventId.hex,
originalAuthor: event.originalAuthor.formatted
})
// Update repost count in UI
if (callbacks.onUpdateRepostCount) {
callbacks.onUpdateRepostCount(event.originalEventId.hex, 1)
}
// Create notification for the original author (if not self)
if (callbacks.onCreateNotification && event.actor.hex !== event.originalAuthor.hex) {
callbacks.onCreateNotification('repost', event.actor.hex, event.originalEventId.hex)
}
}
/**
* Register all content event handlers with the event dispatcher
*/
export function registerContentEventHandlers(): void {
eventDispatcher.on('content.event_bookmarked', handleEventBookmarked)
eventDispatcher.on('content.event_unbookmarked', handleEventUnbookmarked)
eventDispatcher.on('content.bookmark_list_published', handleBookmarkListPublished)
eventDispatcher.on('content.note_pinned', handleNotePinned)
eventDispatcher.on('content.note_unpinned', handleNoteUnpinned)
eventDispatcher.on('content.pins_limit_exceeded', handlePinsLimitExceeded)
eventDispatcher.on('content.pin_list_published', handlePinListPublished)
eventDispatcher.on('content.reaction_added', handleReactionAdded)
eventDispatcher.on('content.reposted', handleContentReposted)
}
/**
* Unregister all content event handlers
*/
export function unregisterContentEventHandlers(): void {
eventDispatcher.off('content.event_bookmarked', handleEventBookmarked)
eventDispatcher.off('content.event_unbookmarked', handleEventUnbookmarked)
eventDispatcher.off('content.bookmark_list_published', handleBookmarkListPublished)
eventDispatcher.off('content.note_pinned', handleNotePinned)
eventDispatcher.off('content.note_unpinned', handleNoteUnpinned)
eventDispatcher.off('content.pins_limit_exceeded', handlePinsLimitExceeded)
eventDispatcher.off('content.pin_list_published', handlePinListPublished)
eventDispatcher.off('content.reaction_added', handleReactionAdded)
eventDispatcher.off('content.reposted', handleContentReposted)
}

View File

@@ -0,0 +1,215 @@
import {
FeedSwitched,
ContentFilterUpdated,
FeedRefreshed,
NoteCreated,
NoteDeleted,
NoteReplied,
UsersMentioned,
TimelineEventsReceived,
TimelineEOSED
} from '@/domain/feed/events'
import { EventHandler, eventDispatcher } from '@/domain/shared'
/**
* Handlers for Feed domain events
*
* These handlers coordinate cross-context updates when feed events occur.
* They enable coordination between Feed, Social, Content, and UI contexts.
*/
/**
* Handler for feed switched events
* Can be used to:
* - Clear timeline caches for the old feed
* - Prefetch content for the new feed
* - Update URL/navigation state
* - Log analytics
*/
export const handleFeedSwitched: EventHandler<FeedSwitched> = async (event) => {
console.debug('[FeedEventHandler] Feed switched:', {
owner: event.owner?.formatted,
fromType: event.fromType?.value ?? 'none',
toType: event.toType.value,
relaySetId: event.relaySetId
})
// Future: Clear old timeline cache
// Future: Trigger new timeline fetch
// Future: Update analytics
}
/**
* Handler for content filter updated events
* Can be used to:
* - Re-filter current timeline with new settings
* - Persist filter preferences
* - Update filter indicators in UI
*/
export const handleContentFilterUpdated: EventHandler<ContentFilterUpdated> = async (event) => {
console.debug('[FeedEventHandler] Content filter updated:', {
owner: event.owner.formatted,
hideRepliesChanged: event.previousFilter.hideReplies !== event.newFilter.hideReplies,
hideRepostsChanged: event.previousFilter.hideReposts !== event.newFilter.hideReposts,
nsfwPolicyChanged: event.previousFilter.nsfwPolicy !== event.newFilter.nsfwPolicy
})
// Future: Trigger timeline re-filter
// Future: Persist filter preferences
}
/**
* Handler for feed refreshed events
* Can be used to:
* - Update last refresh timestamp display
* - Trigger background data fetch
* - Reset scroll position indicators
*/
export const handleFeedRefreshed: EventHandler<FeedRefreshed> = async (event) => {
console.debug('[FeedEventHandler] Feed refreshed:', {
owner: event.owner?.formatted,
feedType: event.feedType.value
})
// Future: Update refresh timestamp in UI
// Future: Trigger stale data cleanup
}
/**
* Handler for note created events
* Can be used to:
* - Add note to local timeline immediately (optimistic UI)
* - Create notifications for mentioned users
* - Update post count displays
*/
export const handleNoteCreated: EventHandler<NoteCreated> = async (event) => {
console.debug('[FeedEventHandler] Note created:', {
author: event.author.formatted,
noteId: event.noteId.hex,
mentionCount: event.mentions.length,
isReply: event.isReply,
isQuote: event.isQuote
})
// Future: Add to local timeline if author is self
// Future: Create mention notifications
}
/**
* Handler for note deleted events
* Can be used to:
* - Remove note from all timelines
* - Update reply counts on parent notes
* - Clean up cached data
*/
export const handleNoteDeleted: EventHandler<NoteDeleted> = async (event) => {
console.debug('[FeedEventHandler] Note deleted:', {
author: event.author.formatted,
noteId: event.noteId.hex
})
// Future: Remove from timeline display
// Future: Remove from caches
}
/**
* Handler for note replied events
* Can be used to:
* - Increment reply count on parent note
* - Create notification for parent note author
* - Update thread view if open
*/
export const handleNoteReplied: EventHandler<NoteReplied> = async (event) => {
console.debug('[FeedEventHandler] Note replied:', {
replier: event.replier.formatted,
replyNoteId: event.replyNoteId.hex,
originalNoteId: event.originalNoteId.hex,
originalAuthor: event.originalAuthor.formatted
})
// Future: Increment reply count
// Future: Create reply notification for parent author
// Future: Update thread view
}
/**
* Handler for users mentioned events
* Can be used to:
* - Create mention notifications for each mentioned user
* - Highlight mentions in the source note
*/
export const handleUsersMentioned: EventHandler<UsersMentioned> = async (event) => {
console.debug('[FeedEventHandler] Users mentioned:', {
author: event.author.formatted,
noteId: event.noteId.hex,
mentionedCount: event.mentionedPubkeys.length
})
// Future: Create mention notifications
}
/**
* Handler for timeline events received
* Can be used to:
* - Update event cache
* - Trigger profile/metadata fetches for new authors
* - Update unread counts
*/
export const handleTimelineEventsReceived: EventHandler<TimelineEventsReceived> = async (event) => {
console.debug('[FeedEventHandler] Timeline events received:', {
feedType: event.feedType.value,
eventCount: event.eventCount,
newestTimestamp: event.newestTimestamp.unix,
isHistorical: event.isHistorical
})
// Future: Prefetch profiles for new authors
// Future: Update new post indicators
}
/**
* Handler for timeline EOSE (end of stored events)
* Can be used to:
* - Mark initial load as complete
* - Switch from loading to live mode
* - Update loading indicators
*/
export const handleTimelineEOSED: EventHandler<TimelineEOSED> = async (event) => {
console.debug('[FeedEventHandler] Timeline EOSE:', {
feedType: event.feedType.value,
totalEvents: event.totalEvents
})
// Future: Update loading state
// Future: Show "up to date" indicator
}
/**
* Register all feed event handlers with the event dispatcher
*/
export function registerFeedEventHandlers(): void {
eventDispatcher.on('feed.switched', handleFeedSwitched)
eventDispatcher.on('feed.content_filter_updated', handleContentFilterUpdated)
eventDispatcher.on('feed.refreshed', handleFeedRefreshed)
eventDispatcher.on('feed.note_created', handleNoteCreated)
eventDispatcher.on('feed.note_deleted', handleNoteDeleted)
eventDispatcher.on('feed.note_replied', handleNoteReplied)
eventDispatcher.on('feed.users_mentioned', handleUsersMentioned)
eventDispatcher.on('feed.timeline_events_received', handleTimelineEventsReceived)
eventDispatcher.on('feed.timeline_eosed', handleTimelineEOSED)
}
/**
* Unregister all feed event handlers
*/
export function unregisterFeedEventHandlers(): void {
eventDispatcher.off('feed.switched', handleFeedSwitched)
eventDispatcher.off('feed.content_filter_updated', handleContentFilterUpdated)
eventDispatcher.off('feed.refreshed', handleFeedRefreshed)
eventDispatcher.off('feed.note_created', handleNoteCreated)
eventDispatcher.off('feed.note_deleted', handleNoteDeleted)
eventDispatcher.off('feed.note_replied', handleNoteReplied)
eventDispatcher.off('feed.users_mentioned', handleUsersMentioned)
eventDispatcher.off('feed.timeline_events_received', handleTimelineEventsReceived)
eventDispatcher.off('feed.timeline_eosed', handleTimelineEOSED)
}

View File

@@ -0,0 +1,220 @@
import {
FavoriteRelayAdded,
FavoriteRelayRemoved,
FavoriteRelaysPublished,
RelaySetCreated,
RelaySetUpdated,
RelaySetDeleted,
MailboxRelayAdded,
MailboxRelayRemoved,
MailboxRelayScopeChanged,
RelayListPublished
} from '@/domain/relay/events'
import { EventHandler, eventDispatcher } from '@/domain/shared'
/**
* Handlers for Relay domain events
*
* These handlers coordinate cross-context updates when relay configuration changes.
* They enable coordination between Relay, Feed, and Identity contexts.
*/
/**
* Handler for favorite relay added events
* Can be used to:
* - Update relay picker UI
* - Add relay to connection pool
*/
export const handleFavoriteRelayAdded: EventHandler<FavoriteRelayAdded> = async (event) => {
console.debug('[RelayEventHandler] Favorite relay added:', {
owner: event.owner.formatted,
relay: event.relayUrl.value
})
// Future: Update relay picker options
// Future: Pre-connect to new favorite relay
}
/**
* Handler for favorite relay removed events
* Can be used to:
* - Update relay picker UI
* - Close connection if no longer needed
*/
export const handleFavoriteRelayRemoved: EventHandler<FavoriteRelayRemoved> = async (event) => {
console.debug('[RelayEventHandler] Favorite relay removed:', {
owner: event.owner.formatted,
relay: event.relayUrl.value
})
// Future: Update relay picker options
}
/**
* Handler for favorite relays published events
* Can be used to:
* - Invalidate relay preference caches
* - Sync with remote state
*/
export const handleFavoriteRelaysPublished: EventHandler<FavoriteRelaysPublished> = async (event) => {
console.debug('[RelayEventHandler] Favorite relays published:', {
owner: event.owner.formatted,
relayCount: event.relayCount
})
// Future: Invalidate caches
}
/**
* Handler for relay set created events
* Can be used to:
* - Update feed type options in UI
* - Add new relay set to navigation
*/
export const handleRelaySetCreated: EventHandler<RelaySetCreated> = async (event) => {
console.debug('[RelayEventHandler] Relay set created:', {
owner: event.owner.formatted,
setId: event.setId,
name: event.name,
relayCount: event.relays.length
})
// Future: Update feed selector options
// Future: Add to relay set navigation
}
/**
* Handler for relay set updated events
* Can be used to:
* - Refresh active feed if using this relay set
* - Update relay set display
*/
export const handleRelaySetUpdated: EventHandler<RelaySetUpdated> = async (event) => {
console.debug('[RelayEventHandler] Relay set updated:', {
owner: event.owner.formatted,
setId: event.setId,
nameChanged: event.nameChanged,
changes: {
addedCount: event.changes.addedRelays?.length ?? 0,
removedCount: event.changes.removedRelays?.length ?? 0
}
})
// Future: Refresh feed if currently using this relay set
// Future: Update relay set display
}
/**
* Handler for relay set deleted events
* Can be used to:
* - Switch to different feed if current feed uses deleted set
* - Remove from navigation
*/
export const handleRelaySetDeleted: EventHandler<RelaySetDeleted> = async (event) => {
console.debug('[RelayEventHandler] Relay set deleted:', {
owner: event.owner.formatted,
setId: event.setId
})
// Future: Switch feed if currently using this relay set
// Future: Remove from feed selector options
}
/**
* Handler for mailbox relay added events
* Can be used to:
* - Update relay list display
* - Connect to new mailbox relay
*/
export const handleMailboxRelayAdded: EventHandler<MailboxRelayAdded> = async (event) => {
console.debug('[RelayEventHandler] Mailbox relay added:', {
owner: event.owner.formatted,
relay: event.relayUrl.value,
scope: event.scope
})
// Future: Update relay list in settings
// Future: Connect to relay based on scope
}
/**
* Handler for mailbox relay removed events
* Can be used to:
* - Update relay list display
* - Disconnect if no longer needed
*/
export const handleMailboxRelayRemoved: EventHandler<MailboxRelayRemoved> = async (event) => {
console.debug('[RelayEventHandler] Mailbox relay removed:', {
owner: event.owner.formatted,
relay: event.relayUrl.value
})
// Future: Update relay list in settings
}
/**
* Handler for mailbox relay scope changed events
* Can be used to:
* - Update relay list display
* - Adjust connection strategy
*/
export const handleMailboxRelayScopeChanged: EventHandler<MailboxRelayScopeChanged> = async (event) => {
console.debug('[RelayEventHandler] Mailbox relay scope changed:', {
owner: event.owner.formatted,
relay: event.relayUrl.value,
fromScope: event.fromScope,
toScope: event.toScope
})
// Future: Update relay list in settings
// Future: Adjust write/read connection strategy
}
/**
* Handler for relay list published events
* Can be used to:
* - Invalidate relay caches
* - Trigger feed refresh if relay configuration changed
*/
export const handleRelayListPublished: EventHandler<RelayListPublished> = async (event) => {
console.debug('[RelayEventHandler] Relay list published:', {
owner: event.owner.formatted,
readRelayCount: event.readRelayCount,
writeRelayCount: event.writeRelayCount
})
// Future: Invalidate relay caches
// Future: Trigger feed refresh if needed
}
/**
* Register all relay event handlers with the event dispatcher
*/
export function registerRelayEventHandlers(): void {
eventDispatcher.on('relay.favorite_added', handleFavoriteRelayAdded)
eventDispatcher.on('relay.favorite_removed', handleFavoriteRelayRemoved)
eventDispatcher.on('relay.favorites_published', handleFavoriteRelaysPublished)
eventDispatcher.on('relay.set_created', handleRelaySetCreated)
eventDispatcher.on('relay.set_updated', handleRelaySetUpdated)
eventDispatcher.on('relay.set_deleted', handleRelaySetDeleted)
eventDispatcher.on('relay.mailbox_added', handleMailboxRelayAdded)
eventDispatcher.on('relay.mailbox_removed', handleMailboxRelayRemoved)
eventDispatcher.on('relay.mailbox_scope_changed', handleMailboxRelayScopeChanged)
eventDispatcher.on('relay.list_published', handleRelayListPublished)
}
/**
* Unregister all relay event handlers
*/
export function unregisterRelayEventHandlers(): void {
eventDispatcher.off('relay.favorite_added', handleFavoriteRelayAdded)
eventDispatcher.off('relay.favorite_removed', handleFavoriteRelayRemoved)
eventDispatcher.off('relay.favorites_published', handleFavoriteRelaysPublished)
eventDispatcher.off('relay.set_created', handleRelaySetCreated)
eventDispatcher.off('relay.set_updated', handleRelaySetUpdated)
eventDispatcher.off('relay.set_deleted', handleRelaySetDeleted)
eventDispatcher.off('relay.mailbox_added', handleMailboxRelayAdded)
eventDispatcher.off('relay.mailbox_removed', handleMailboxRelayRemoved)
eventDispatcher.off('relay.mailbox_scope_changed', handleMailboxRelayScopeChanged)
eventDispatcher.off('relay.list_published', handleRelayListPublished)
}

View File

@@ -0,0 +1,205 @@
import {
UserFollowed,
UserUnfollowed,
UserMuted,
UserUnmuted,
MuteVisibilityChanged,
FollowListPublished,
MuteListPublished
} from '@/domain/social/events'
import { EventHandler, eventDispatcher } from '@/domain/shared'
/**
* Handlers for social domain events
*
* These handlers coordinate cross-context updates when social events occur.
* They bridge the Social context with Feed, Notification, and Cache contexts.
*/
/**
* Callback type for feed refresh requests
*/
export type FeedRefreshCallback = () => void
/**
* Callback type for content refiltering requests
*/
export type RefilterCallback = () => void
/**
* Callback type for profile prefetch requests
*/
export type PrefetchProfileCallback = (pubkey: string) => void
/**
* Service callbacks that can be injected for cross-context coordination
*/
export interface SocialHandlerCallbacks {
onFeedRefreshNeeded?: FeedRefreshCallback
onRefilterNeeded?: RefilterCallback
onPrefetchProfile?: PrefetchProfileCallback
}
let callbacks: SocialHandlerCallbacks = {}
/**
* Set the callbacks for cross-context coordination
* Call this during provider initialization
*/
export function setSocialHandlerCallbacks(newCallbacks: SocialHandlerCallbacks): void {
callbacks = { ...callbacks, ...newCallbacks }
}
/**
* Clear all callbacks (for cleanup/testing)
*/
export function clearSocialHandlerCallbacks(): void {
callbacks = {}
}
/**
* Handler for user followed events
* Coordinates with:
* - Feed context: Add followed user's content to timeline
* - Cache context: Prefetch followed user's profile and notes
*/
export const handleUserFollowed: EventHandler<UserFollowed> = async (event) => {
console.debug('[SocialEventHandler] User followed:', {
actor: event.actor.formatted,
followed: event.followed.formatted,
petname: event.petname
})
// Prefetch the followed user's profile for better UX
if (callbacks.onPrefetchProfile) {
callbacks.onPrefetchProfile(event.followed.hex)
}
}
/**
* Handler for user unfollowed events
* Can be used to:
* - Update feed context to exclude unfollowed user's content
* - Clean up cached data for unfollowed user
*/
export const handleUserUnfollowed: EventHandler<UserUnfollowed> = async (event) => {
console.debug('[SocialEventHandler] User unfollowed:', {
actor: event.actor.formatted,
unfollowed: event.unfollowed.formatted
})
// Future: Dispatch to feed context to update content sources
}
/**
* Handler for user muted events
* Coordinates with:
* - Feed context: Refilter timeline to hide muted user's content
* - Notification context: Filter notifications from muted user
* - DM context: Update DM filtering
*/
export const handleUserMuted: EventHandler<UserMuted> = async (event) => {
console.debug('[SocialEventHandler] User muted:', {
actor: event.actor.formatted,
muted: event.muted.formatted,
visibility: event.visibility
})
// Trigger immediate refiltering of current timeline
if (callbacks.onRefilterNeeded) {
callbacks.onRefilterNeeded()
}
}
/**
* Handler for user unmuted events
* Coordinates with:
* - Feed context: Refilter timeline to show unmuted user's content
* - Notification context: Restore notifications from unmuted user
*/
export const handleUserUnmuted: EventHandler<UserUnmuted> = async (event) => {
console.debug('[SocialEventHandler] User unmuted:', {
actor: event.actor.formatted,
unmuted: event.unmuted.formatted
})
// Trigger refiltering to restore unmuted user's content
if (callbacks.onRefilterNeeded) {
callbacks.onRefilterNeeded()
}
}
/**
* Handler for mute visibility changed events
*/
export const handleMuteVisibilityChanged: EventHandler<MuteVisibilityChanged> = async (event) => {
console.debug('[SocialEventHandler] Mute visibility changed:', {
actor: event.actor.formatted,
target: event.target.formatted,
from: event.from,
to: event.to
})
}
/**
* Handler for follow list published events
* Coordinates with:
* - Feed context: Refresh following feed with new list
* - Cache context: Invalidate author caches
*/
export const handleFollowListPublished: EventHandler<FollowListPublished> = async (event) => {
console.debug('[SocialEventHandler] Follow list published:', {
owner: event.owner.formatted,
followingCount: event.followingCount
})
// Trigger feed refresh to reflect new following list
if (callbacks.onFeedRefreshNeeded) {
callbacks.onFeedRefreshNeeded()
}
}
/**
* Handler for mute list published events
* Coordinates with:
* - Feed context: Refilter timeline with new mute list
* - Notification context: Update notification filtering
*/
export const handleMuteListPublished: EventHandler<MuteListPublished> = async (event) => {
console.debug('[SocialEventHandler] Mute list published:', {
owner: event.owner.formatted,
publicMuteCount: event.publicMuteCount,
privateMuteCount: event.privateMuteCount
})
// Trigger refiltering with updated mute list
if (callbacks.onRefilterNeeded) {
callbacks.onRefilterNeeded()
}
}
/**
* Register all social event handlers with the event dispatcher
*/
export function registerSocialEventHandlers(): void {
eventDispatcher.on('social.user_followed', handleUserFollowed)
eventDispatcher.on('social.user_unfollowed', handleUserUnfollowed)
eventDispatcher.on('social.user_muted', handleUserMuted)
eventDispatcher.on('social.user_unmuted', handleUserUnmuted)
eventDispatcher.on('social.mute_visibility_changed', handleMuteVisibilityChanged)
eventDispatcher.on('social.follow_list_published', handleFollowListPublished)
eventDispatcher.on('social.mute_list_published', handleMuteListPublished)
}
/**
* Unregister all social event handlers
*/
export function unregisterSocialEventHandlers(): void {
eventDispatcher.off('social.user_followed', handleUserFollowed)
eventDispatcher.off('social.user_unfollowed', handleUserUnfollowed)
eventDispatcher.off('social.user_muted', handleUserMuted)
eventDispatcher.off('social.user_unmuted', handleUserUnmuted)
eventDispatcher.off('social.mute_visibility_changed', handleMuteVisibilityChanged)
eventDispatcher.off('social.follow_list_published', handleFollowListPublished)
eventDispatcher.off('social.mute_list_published', handleMuteListPublished)
}

View File

@@ -0,0 +1,121 @@
/**
* Domain Event Handlers
*
* Application-level handlers that coordinate cross-context updates
* when domain events occur.
*/
// Social Event Handlers
export {
registerSocialEventHandlers,
unregisterSocialEventHandlers,
setSocialHandlerCallbacks,
clearSocialHandlerCallbacks,
handleUserFollowed,
handleUserUnfollowed,
handleUserMuted,
handleUserUnmuted,
handleMuteVisibilityChanged,
handleFollowListPublished,
handleMuteListPublished,
type SocialHandlerCallbacks,
type FeedRefreshCallback,
type RefilterCallback,
type PrefetchProfileCallback
} from './SocialEventHandlers'
// Content Event Handlers
export {
registerContentEventHandlers,
unregisterContentEventHandlers,
setContentHandlerCallbacks,
clearContentHandlerCallbacks,
handleEventBookmarked,
handleEventUnbookmarked,
handleBookmarkListPublished,
handleNotePinned,
handleNoteUnpinned,
handlePinsLimitExceeded,
handlePinListPublished,
handleReactionAdded,
handleContentReposted,
type ContentHandlerCallbacks,
type UpdateReactionCountCallback,
type UpdateRepostCountCallback,
type CreateNotificationCallback,
type ShowToastCallback,
type UpdateProfilePinsCallback
} from './ContentEventHandlers'
// Feed Event Handlers
export {
registerFeedEventHandlers,
unregisterFeedEventHandlers,
handleFeedSwitched,
handleContentFilterUpdated,
handleFeedRefreshed,
handleNoteCreated,
handleNoteDeleted,
handleNoteReplied,
handleUsersMentioned,
handleTimelineEventsReceived,
handleTimelineEOSED
} from './FeedEventHandlers'
// Relay Event Handlers
export {
registerRelayEventHandlers,
unregisterRelayEventHandlers,
handleFavoriteRelayAdded,
handleFavoriteRelayRemoved,
handleFavoriteRelaysPublished,
handleRelaySetCreated,
handleRelaySetUpdated,
handleRelaySetDeleted,
handleMailboxRelayAdded,
handleMailboxRelayRemoved,
handleMailboxRelayScopeChanged,
handleRelayListPublished
} from './RelayEventHandlers'
/**
* Initialize all domain event handlers
*
* Call this once during application startup to register all handlers
* with the event dispatcher.
*/
export function initializeEventHandlers(): void {
const { registerSocialEventHandlers } = require('./SocialEventHandlers')
const { registerContentEventHandlers } = require('./ContentEventHandlers')
const { registerFeedEventHandlers } = require('./FeedEventHandlers')
const { registerRelayEventHandlers } = require('./RelayEventHandlers')
registerSocialEventHandlers()
registerContentEventHandlers()
registerFeedEventHandlers()
registerRelayEventHandlers()
console.debug('[EventHandlers] All domain event handlers registered')
}
/**
* Cleanup all domain event handlers
*
* Call this during application shutdown or for testing purposes.
*/
export function cleanupEventHandlers(): void {
const { unregisterSocialEventHandlers, clearSocialHandlerCallbacks } = require('./SocialEventHandlers')
const { unregisterContentEventHandlers, clearContentHandlerCallbacks } = require('./ContentEventHandlers')
const { unregisterFeedEventHandlers } = require('./FeedEventHandlers')
const { unregisterRelayEventHandlers } = require('./RelayEventHandlers')
unregisterSocialEventHandlers()
unregisterContentEventHandlers()
unregisterFeedEventHandlers()
unregisterRelayEventHandlers()
clearSocialHandlerCallbacks()
clearContentHandlerCallbacks()
console.debug('[EventHandlers] All domain event handlers unregistered')
}

View File

@@ -10,3 +10,13 @@ export type { RelaySelectorOptions } from './RelaySelector'
export { PublishingService, publishingService } from './PublishingService'
export type { DraftEvent, PublishNoteOptions } from './PublishingService'
// Event Handlers
export {
initializeEventHandlers,
cleanupEventHandlers,
registerSocialEventHandlers,
unregisterSocialEventHandlers,
registerContentEventHandlers,
unregisterContentEventHandlers
} from './handlers'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button'
import { Pubkey } from '@/domain'
import { isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { TAccountPointer } from '@/types'
@@ -43,7 +43,7 @@ export default function AccountList({
<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)}
{Pubkey.tryFromString(act.pubkey)?.formatNpub(12) ?? act.pubkey.slice(0, 8)}
</div>
</div>
</div>

View File

@@ -1,78 +1,12 @@
import QrScannerModal from '@/components/QrScannerModal'
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 { useCallback, useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ScanLine, X } from 'lucide-react'
import QrScanner from 'qr-scanner'
function QrScannerModal({
onScan,
onClose
}: {
onScan: (result: string) => void
onClose: () => void
}) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement>(null)
const scannerRef = useRef<QrScanner | null>(null)
const [error, setError] = useState<string | null>(null)
const handleScan = useCallback(
(result: QrScanner.ScanResult) => {
onScan(result.data)
onClose()
},
[onScan, onClose]
)
useEffect(() => {
if (!videoRef.current) return
const scanner = new QrScanner(videoRef.current, handleScan, {
preferredCamera: 'environment',
highlightScanRegion: true,
highlightCodeOutline: true
})
scannerRef.current = scanner
scanner.start().catch(() => {
setError(t('Failed to access camera'))
})
return () => {
scanner.destroy()
}
}, [handleScan, t])
return (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
<div className="relative w-full max-w-sm mx-4">
<Button
variant="ghost"
size="icon"
className="absolute -top-12 right-0 text-white hover:bg-white/20"
onClick={onClose}
>
<X className="h-6 w-6" />
</Button>
<div className="rounded-lg overflow-hidden bg-black">
{error ? (
<div className="p-8 text-center text-destructive">{error}</div>
) : (
<video ref={videoRef} className="w-full" />
)}
</div>
<p className="text-center text-white/70 text-sm mt-4">
{t('Point camera at QR code')}
</p>
</div>
</div>
)
}
import { ScanLine } from 'lucide-react'
export default function PrivateKeyLogin({
back,

View File

@@ -0,0 +1,41 @@
import { cn } from '@/lib/utils'
import { TActionType, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { MessageSquare, Repeat2, Quote, Heart, Zap } from 'lucide-react'
const ACTIONS: { type: TActionType; icon: typeof MessageSquare; label: string }[] = [
{ type: 'reply', icon: MessageSquare, label: 'Reply' },
{ type: 'repost', icon: Repeat2, label: 'Repost' },
{ type: 'quote', icon: Quote, label: 'Quote' },
{ type: 'react', icon: Heart, label: 'React' },
{ type: 'zap', icon: Zap, label: 'Zap' }
]
export default function ActionModeOverlay() {
const { actionMode, isEnabled } = useKeyboardNavigation()
if (!isEnabled || !actionMode.active) return null
return (
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 pointer-events-none">
<div className="flex gap-1 bg-background/95 backdrop-blur-sm border rounded-full px-3 py-2 shadow-lg">
{ACTIONS.map(({ type, icon: Icon, label }) => (
<div
key={type}
className={cn(
'flex flex-col items-center gap-1 p-2 rounded-full transition-all duration-150',
actionMode.selectedAction === type
? 'bg-primary text-primary-foreground scale-110'
: 'text-muted-foreground'
)}
title={label}
>
<Icon className="size-5" />
</div>
))}
</div>
<div className="text-center text-xs text-muted-foreground mt-2">
Tab to cycle, Enter to activate, Esc to cancel
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { userIdToPubkey } from '@/lib/pubkey'
import { Pubkey } from '@/domain'
import { useFollowList } from '@/providers/FollowListProvider'
import { UserRoundCheck } from 'lucide-react'
import { useMemo } from 'react'
@@ -10,7 +10,7 @@ export default function FollowingBadge({ pubkey, userId }: { pubkey?: string; us
const isFollowing = useMemo(() => {
if (pubkey) return followingSet.has(pubkey)
return userId ? followingSet.has(userIdToPubkey(userId)) : false
return userId ? followingSet.has(Pubkey.tryFromString(userId)?.hex ?? userId) : false
}, [followingSet, pubkey, userId])
if (!isFollowing) return null

View File

@@ -0,0 +1,208 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@/components/ui/accordion'
import { Keyboard, Layout, MessageSquare, Settings, User, Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function Help() {
const { t } = useTranslation()
return (
<div className="px-4 py-4">
<Accordion type="single" collapsible className="space-y-2">
<AccordionItem value="keyboard" className="border rounded-lg px-4">
<AccordionTrigger className="py-3">
<div className="flex items-center gap-3">
<Keyboard className="size-5 text-muted-foreground" />
<span className="font-medium">{t('Keyboard Navigation')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4 text-sm text-muted-foreground">
<p>{t('Navigate the app entirely with your keyboard:')}</p>
<p className="font-medium">{t('Toggle Keyboard Mode:')}</p>
<div className="space-y-2">
<KeyBinding keys={['⇧K']} description={t('Toggle keyboard navigation on/off')} />
<KeyBinding keys={['Esc', 'Esc', 'Esc']} description={t('Triple-Escape to quickly exit keyboard mode')} />
</div>
<p className="text-xs opacity-70">{t('You can also click the keyboard button in the sidebar to toggle.')}</p>
<p className="font-medium mt-4">{t('Movement:')}</p>
<div className="space-y-2">
<KeyBinding keys={['↑', '↓']} altKeys={['k', 'j']} description={t('Move between items in a list')} />
<KeyBinding keys={['Tab']} description={t('Switch to next column (Shift+Tab for previous)')} />
<KeyBinding keys={['Page Up']} description={t('Jump to top and focus first item')} />
</div>
<p className="font-medium mt-4">{t('Actions:')}</p>
<div className="space-y-2">
<KeyBinding keys={['→', 'Enter']} altKeys={['l']} description={t('Activate the selected item')} />
<KeyBinding keys={['←']} altKeys={['h']} description={t('Go back (close panel or move to sidebar)')} />
<KeyBinding keys={['Escape']} description={t('Close current view or cancel')} />
</div>
<p className="font-medium mt-4">{t('Note Actions (when a note is selected):')}</p>
<div className="space-y-2">
<KeyBinding keys={['r']} description={t('Reply')} />
<KeyBinding keys={['p']} description={t('Repost')} />
<KeyBinding keys={['q']} description={t('Quote')} />
<KeyBinding keys={['R']} description={t('React with emoji')} />
<KeyBinding keys={['z']} description={t('Zap (send sats)')} />
</div>
<p className="text-xs opacity-70 pt-2">{t('Selected items are centered on screen for easy viewing.')}</p>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="layout" className="border rounded-lg px-4">
<AccordionTrigger className="py-3">
<div className="flex items-center gap-3">
<Layout className="size-5 text-muted-foreground" />
<span className="font-medium">{t('Layout & Navigation')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('The app uses a multi-column layout:')}</p>
<ul className="list-disc list-inside space-y-1.5 ml-2">
<li>{t('Sidebar: Quick access to main sections')}</li>
<li>{t('Primary column: Feed, notifications, inbox, search')}</li>
<li>{t('Secondary column: Note details, user profiles, relay info')}</li>
</ul>
<p>{t('On mobile or single-column mode, pages stack on top of each other.')}</p>
<p>{t('Use the columns button at the bottom of the sidebar to switch between layouts.')}</p>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="posting" className="border rounded-lg px-4">
<AccordionTrigger className="py-3">
<div className="flex items-center gap-3">
<MessageSquare className="size-5 text-muted-foreground" />
<span className="font-medium">{t('Posting & Interactions')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-3 text-sm text-muted-foreground">
<p><strong>{t('Creating Posts:')}</strong></p>
<ul className="list-disc list-inside space-y-1.5 ml-2">
<li>{t('Click the post button in the sidebar to compose a new note')}</li>
<li>{t('Use @ to mention users and # for hashtags')}</li>
<li>{t('Drag and drop images or use the attachment button')}</li>
</ul>
<p className="pt-2"><strong>{t('Interacting with Notes:')}</strong></p>
<ul className="list-disc list-inside space-y-1.5 ml-2">
<li>{t('Reply: Continue the conversation')}</li>
<li>{t('Repost: Share to your followers')}</li>
<li>{t('Quote: Repost with your own comment')}</li>
<li>{t('React: Like or add emoji reactions')}</li>
<li>{t('Zap: Send Bitcoin tips via Lightning')}</li>
</ul>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="zaps" className="border rounded-lg px-4">
<AccordionTrigger className="py-3">
<div className="flex items-center gap-3">
<Zap className="size-5 text-muted-foreground" />
<span className="font-medium">{t('Zaps & Lightning')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('Zaps are Bitcoin tips sent via the Lightning Network:')}</p>
<ul className="list-disc list-inside space-y-1.5 ml-2">
<li>{t('To receive zaps, add a Lightning address to your profile')}</li>
<li>{t('To send zaps, connect a Lightning wallet in Settings')}</li>
<li>{t('Click the zap icon on any note to send sats')}</li>
<li>{t('Long-press for custom zap amounts')}</li>
</ul>
<p className="pt-2">{t('Supported wallets include Alby, NWC-compatible wallets, and Cashu mints.')}</p>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="accounts" className="border rounded-lg px-4">
<AccordionTrigger className="py-3">
<div className="flex items-center gap-3">
<User className="size-5 text-muted-foreground" />
<span className="font-medium">{t('Account & Login')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('Nostr uses public/private key pairs for identity:')}</p>
<ul className="list-disc list-inside space-y-1.5 ml-2">
<li><strong>npub</strong>: {t('Your public key (share freely)')}</li>
<li><strong>nsec</strong>: {t('Your private key (keep secret!)')}</li>
</ul>
<p className="pt-2"><strong>{t('Login Methods:')}</strong></p>
<ul className="list-disc list-inside space-y-1.5 ml-2">
<li><strong>{t('Browser Extension (NIP-07)')}</strong>: {t('Recommended. Uses extensions like Alby or nos2x')}</li>
<li><strong>{t('Remote Signer (NIP-46)')}</strong>: {t('Connect to bunker signers like Amber or nsecBunker')}</li>
<li><strong>{t('Private Key')}</strong>: {t('Enter nsec directly (less secure)')}</li>
<li><strong>{t('View Only')}</strong>: {t('Browse with an npub without signing')}</li>
</ul>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="settings" className="border rounded-lg px-4">
<AccordionTrigger className="py-3">
<div className="flex items-center gap-3">
<Settings className="size-5 text-muted-foreground" />
<span className="font-medium">{t('Settings Overview')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-3 text-sm text-muted-foreground">
<ul className="list-disc list-inside space-y-1.5 ml-2">
<li><strong>{t('General')}</strong>: {t('Language, content preferences, mutes')}</li>
<li><strong>{t('Appearance')}</strong>: {t('Theme, layout, visual options')}</li>
<li><strong>{t('Relays')}</strong>: {t('Configure which relays to read from and write to')}</li>
<li><strong>{t('Posts')}</strong>: {t('Posting preferences and default settings')}</li>
<li><strong>{t('Wallet')}</strong>: {t('Lightning wallet connection for zaps')}</li>
<li><strong>{t('Emoji Packs')}</strong>: {t('Custom emoji sets')}</li>
<li><strong>{t('System')}</strong>: {t('Debug tools and app information')}</li>
</ul>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)
}
function KeyBinding({
keys,
altKeys,
description
}: {
keys: string[]
altKeys?: string[]
description: string
}) {
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
{keys.map((key) => (
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
{key}
</kbd>
))}
{altKeys && (
<>
<span className="text-xs text-muted-foreground mx-1">/</span>
{altKeys.map((key) => (
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
{key}
</kbd>
))}
</>
)}
</div>
<span>{description}</span>
</div>
)
}

View File

@@ -1,25 +1,39 @@
import UserAvatar from '@/components/UserAvatar'
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
import { formatTimestamp } from '@/lib/timestamp'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { TConversation, TProfile } from '@/types'
import { Lock, Users } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Lock, Users, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
interface ConversationItemProps {
conversation: TConversation
isActive: boolean
isFollowing: boolean
onClick: () => void
onClose?: () => void
navIndex?: number
}
export default function ConversationItem({
conversation,
isActive,
isFollowing,
onClick
onClick,
onClose,
navIndex
}: ConversationItemProps) {
const [profile, setProfile] = useState<TProfile | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const handleActivate = useCallback(() => {
buttonRef.current?.click()
}, [])
const { ref: navRef, isSelected } = useKeyboardNavigable(1, navIndex ?? 0, {
meta: { type: 'sidebar', onActivate: handleActivate }
})
useEffect(() => {
const fetchProfileData = async () => {
@@ -39,13 +53,16 @@ export default function ConversationItem({
const formattedTime = formatTimestamp(conversation.lastMessageAt)
return (
<button
onClick={onClick}
className={cn(
'w-full flex items-start gap-3 p-3 hover:bg-accent/50 transition-colors text-left',
isActive && 'bg-accent'
)}
>
<div ref={navRef} className="scroll-mt-[6.5rem]">
<button
ref={buttonRef}
onClick={onClick}
className={cn(
'w-full flex items-start gap-3 p-3 hover:bg-accent/50 transition-colors text-left',
isActive && 'bg-accent',
isSelected && 'ring-2 ring-primary ring-inset'
)}
>
<UserAvatar userId={conversation.partnerPubkey} className="size-10 flex-shrink-0" />
<div className="flex-1 min-w-0">
@@ -58,7 +75,21 @@ export default function ConversationItem({
</span>
)}
</div>
<span className="text-xs text-muted-foreground flex-shrink-0">{formattedTime}</span>
<div className="flex items-center gap-1 flex-shrink-0">
<span className="text-xs text-muted-foreground">{formattedTime}</span>
{isActive && onClose && (
<button
onClick={(e) => {
e.stopPropagation()
onClose()
}}
className="p-0.5 rounded hover:bg-muted-foreground/20 transition-colors"
title="Close conversation"
>
<X className="size-4 text-muted-foreground" />
</button>
)}
</div>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
@@ -76,6 +107,7 @@ export default function ConversationItem({
</span>
)}
</div>
</button>
</button>
</div>
)
}

View File

@@ -1,3 +1,5 @@
import { toDMConversation } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useDM } from '@/providers/DMProvider'
import { useFollowList } from '@/providers/FollowListProvider'
import { useMuteList } from '@/providers/MuteListProvider'
@@ -17,6 +19,7 @@ import ConversationItem from './ConversationItem'
export default function ConversationList() {
const { t } = useTranslation()
const { push, pop } = useSecondaryPage()
const {
conversations,
currentConversation,
@@ -122,13 +125,24 @@ export default function ConversationList() {
</div>
) : (
<div className="divide-y">
{sortedConversations.map((conversation) => (
{sortedConversations.map((conversation, index) => (
<ConversationItem
key={conversation.partnerPubkey}
conversation={conversation}
isActive={currentConversation === conversation.partnerPubkey}
isFollowing={followingSet.has(conversation.partnerPubkey)}
onClick={() => selectConversation(conversation.partnerPubkey)}
navIndex={index}
onClick={() => {
// If already viewing a different conversation, pop first to replace
if (currentConversation && currentConversation !== conversation.partnerPubkey) {
pop()
}
push(toDMConversation(conversation.partnerPubkey))
}}
onClose={() => {
selectConversation(null)
pop()
}}
/>
))}
{/* Sentinel element for infinite scroll */}

View File

@@ -1,27 +1,14 @@
import { useDM } from '@/providers/DMProvider'
import { Loader2, RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ConversationList from './ConversationList'
import MessageView from './MessageView'
import { Button } from '../ui/button'
export default function InboxContent() {
const { t } = useTranslation()
const { isLoading, error, refreshConversations, currentConversation, selectConversation } =
useDM()
const [isMobileView, setIsMobileView] = useState(false)
const { isLoading, error, refreshConversations } = useDM()
useEffect(() => {
const checkMobile = () => {
setIsMobileView(window.innerWidth < 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
if (isLoading && !currentConversation) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -44,37 +31,10 @@ export default function InboxContent() {
)
}
// Mobile view: show either list or conversation
if (isMobileView) {
if (currentConversation) {
return (
<div className="h-[calc(100vh-8rem)]">
<MessageView onBack={() => selectConversation(null)} />
</div>
)
}
return (
<div className="h-[calc(100vh-8rem)]">
<ConversationList />
</div>
)
}
// Desktop view: split pane
// Conversations list - clicking opens in secondary panel (or overlay on mobile)
return (
<div className="flex h-[calc(100vh-8rem)]">
<div className="w-80 border-r flex-shrink-0 overflow-hidden">
<ConversationList />
</div>
<div className="flex-1 overflow-hidden">
{currentConversation ? (
<MessageView />
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p>{t('Select a conversation to view messages')}</p>
</div>
)}
</div>
<div className="h-[calc(100vh-8rem)]">
<ConversationList />
</div>
)
}

View File

@@ -1,6 +1,8 @@
import { cn } from '@/lib/utils'
import { useDM } from '@/providers/DMProvider'
import { AlertCircle, Loader2, Send } from 'lucide-react'
import { useRef, useState } from 'react'
import { useNostr } from '@/providers/NostrProvider'
import { AlertCircle, ChevronDown, ChevronUp, Loader2, Send } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
import { Textarea } from '../ui/textarea'
@@ -8,18 +10,47 @@ import { Textarea } from '../ui/textarea'
export default function MessageComposer() {
const { t } = useTranslation()
const { sendMessage, currentConversation } = useDM()
const { relayList } = useNostr()
const [message, setMessage] = useState('')
const [isSending, setIsSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showRelays, setShowRelays] = useState(false)
const [selectedRelays, setSelectedRelays] = useState<Set<string>>(new Set())
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Get user's write relays
const writeRelays = useMemo(() => relayList?.write || [], [relayList])
// Initialize selected relays when write relays change
useEffect(() => {
if (writeRelays.length > 0 && selectedRelays.size === 0) {
setSelectedRelays(new Set(writeRelays))
}
}, [writeRelays])
const toggleRelay = (url: string) => {
setSelectedRelays((prev) => {
const next = new Set(prev)
if (next.has(url)) {
// Don't allow deselecting all relays
if (next.size > 1) {
next.delete(url)
}
} else {
next.add(url)
}
return next
})
}
const handleSend = async () => {
if (!message.trim() || !currentConversation || isSending) return
setIsSending(true)
setError(null)
try {
await sendMessage(message.trim())
const relaysToUse = Array.from(selectedRelays)
await sendMessage(message.trim(), relaysToUse.length > 0 ? relaysToUse : undefined)
setMessage('')
// Return focus to input after sending
textareaRef.current?.focus()
@@ -38,6 +69,11 @@ export default function MessageComposer() {
}
}
// Format relay URL for display
const formatRelayUrl = (url: string) => {
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
}
return (
<div className="p-3 space-y-2">
{error && (
@@ -46,6 +82,41 @@ export default function MessageComposer() {
<span>{error}</span>
</div>
)}
{/* Relay selector */}
{writeRelays.length > 0 && (
<div className="space-y-1">
<button
onClick={() => setShowRelays(!showRelays)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showRelays ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
<span>
{t('Relays')} ({selectedRelays.size}/{writeRelays.length})
</span>
</button>
{showRelays && (
<div className="flex flex-wrap gap-1">
{writeRelays.map((url) => (
<button
key={url}
onClick={() => toggleRelay(url)}
className={cn(
'text-xs px-2 py-0.5 rounded-full border transition-colors',
selectedRelays.has(url)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted text-muted-foreground border-muted hover:border-primary/50'
)}
title={url}
>
{formatRelayUrl(url)}
</button>
))}
</div>
)}
</div>
)}
<div className="flex items-end gap-2">
<Textarea
ref={textareaRef}

View File

@@ -0,0 +1,120 @@
import { useSecondaryPage } from '@/PageManager'
import {
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
parseContent
} from '@/lib/content-parser'
import { toNote, toProfile } from '@/lib/link'
import { truncateUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useMemo } from 'react'
interface MessageContentProps {
content: string
className?: string
/** If true, links will be styled for dark background (primary-foreground color) */
isOwnMessage?: boolean
}
/**
* Renders DM message content with linkified URLs and nostr entities.
* - URLs open in new tab
* - nostr:npub/nprofile opens user profile in secondary pane
* - nostr:note1/nevent opens note in secondary pane
*/
export default function MessageContent({ content, className, isOwnMessage }: MessageContentProps) {
const { push } = useSecondaryPage()
const nodes = useMemo(() => {
return parseContent(content, [EmbeddedEventParser, EmbeddedMentionParser, EmbeddedUrlParser])
}, [content])
const linkClass = cn(
'underline cursor-pointer hover:opacity-80',
isOwnMessage ? 'text-primary-foreground' : 'text-primary'
)
return (
<span className={cn('whitespace-pre-wrap break-words', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
// URLs - open in new tab
if (node.type === 'url' || node.type === 'image' || node.type === 'media') {
const url = node.data as string
return (
<a
key={index}
href={url}
target="_blank"
rel="noreferrer"
className={linkClass}
onClick={(e) => e.stopPropagation()}
>
{truncateUrl(url)}
</a>
)
}
// YouTube and X posts - open in new tab
if (node.type === 'youtube' || node.type === 'x-post') {
const url = node.data as string
return (
<a
key={index}
href={url}
target="_blank"
rel="noreferrer"
className={linkClass}
onClick={(e) => e.stopPropagation()}
>
{truncateUrl(url)}
</a>
)
}
// nostr: mention (npub/nprofile) - open profile in secondary pane
if (node.type === 'mention') {
const bech32 = (node.data as string).replace('nostr:', '')
return (
<button
key={index}
className={linkClass}
onClick={(e) => {
e.stopPropagation()
push(toProfile(bech32))
}}
>
@{bech32.slice(0, 12)}...
</button>
)
}
// nostr: event (note1/nevent/naddr) - open note in secondary pane
if (node.type === 'event') {
const bech32 = (node.data as string).replace('nostr:', '')
// Determine display based on prefix
const isNote = bech32.startsWith('note1')
const prefix = isNote ? 'note' : bech32.startsWith('nevent') ? 'nevent' : 'naddr'
return (
<button
key={index}
className={linkClass}
onClick={(e) => {
e.stopPropagation()
push(toNote(bech32))
}}
>
{prefix}:{bech32.slice(prefix.length, prefix.length + 8)}...
</button>
)
}
return null
})}
</span>
)
}

View File

@@ -6,7 +6,7 @@ import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { TDirectMessage, TProfile } from '@/types'
import { ArrowLeft, ChevronDown, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
import { ChevronDown, ChevronUp, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
@@ -19,15 +19,17 @@ import {
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import MessageComposer from './MessageComposer'
import MessageContent from './MessageContent'
import MessageInfoModal from './MessageInfoModal'
import ConversationSettingsModal from './ConversationSettingsModal'
import { useFollowList } from '@/providers/FollowListProvider'
interface MessageViewProps {
onBack?: () => void
hideHeader?: boolean
}
export default function MessageView({ onBack }: MessageViewProps) {
export default function MessageView({ onBack, hideHeader }: MessageViewProps) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const {
@@ -58,9 +60,28 @@ export default function MessageView({ onBack }: MessageViewProps) {
const [newMessageCount, setNewMessageCount] = useState(0)
const lastMessageCountRef = useRef(0)
const isAtBottomRef = useRef(true)
// Progressive loading: start with 20 messages, load more on demand
const [visibleLimit, setVisibleLimit] = useState(20)
const LOAD_MORE_INCREMENT = 20
const isFollowing = currentConversation ? followingSet.has(currentConversation) : false
// Calculate visible messages (show most recent, allow loading older)
const hasMoreMessages = messages.length > visibleLimit
const visibleMessages = hasMoreMessages
? messages.slice(-visibleLimit) // Show last N messages (most recent)
: messages
// Load more older messages
const loadMoreMessages = () => {
setVisibleLimit((prev) => prev + LOAD_MORE_INCREMENT)
}
// Reset visible limit when conversation changes
useEffect(() => {
setVisibleLimit(20)
}, [currentConversation])
// Handle pulsing animation for new conversations
useEffect(() => {
if (isNewConversation) {
@@ -164,6 +185,20 @@ export default function MessageView({ onBack }: MessageViewProps) {
lastMessageCountRef.current = 0
}, [currentConversation])
// Scroll to bottom when conversation opens and messages are loaded
const hasMessages = messages.length > 0
useEffect(() => {
if (currentConversation && hasMessages && scrollRef.current) {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
lastMessageCountRef.current = messages.length
}
})
}
}, [currentConversation, hasMessages])
if (!currentConversation || !pubkey) {
return null
}
@@ -172,108 +207,116 @@ export default function MessageView({ onBack }: MessageViewProps) {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 p-3 border-b">
{isSelectionMode ? (
// Selection mode header
<>
<Button
variant="ghost"
size="icon"
onClick={clearSelection}
className="size-8"
title={t('Cancel')}
>
<X className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Trash2 className="size-4 text-destructive" />
<span className="font-medium text-sm">{t('Delete')}</span>
</div>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={deleteSelectedMessages}
disabled={selectedMessages.size === 0}
className="text-xs"
>
{t('Selected')} ({selectedMessages.size})
</Button>
<Button
variant="destructive"
size="sm"
onClick={deleteAllInConversation}
className="text-xs"
>
{t('All')}
</Button>
</>
) : (
// Normal header
<>
{onBack && (
<Button variant="ghost" size="icon" onClick={onBack} className="size-8">
<ArrowLeft className="size-4" />
{/* Header - show when not hidden, or when in selection mode */}
{(!hideHeader || isSelectionMode) && (
<div className="flex items-center gap-3 p-3 border-b">
{isSelectionMode ? (
// Selection mode header
<>
<Button
variant="ghost"
size="icon"
onClick={clearSelection}
className="size-8"
title={t('Cancel')}
>
<X className="size-4" />
</Button>
)}
<UserAvatar userId={currentConversation} className="size-8" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm truncate">{displayName}</span>
{isFollowing && (
<span title="Following">
<Users className="size-3 text-primary" />
</span>
<div className="flex items-center gap-2">
<Trash2 className="size-4 text-destructive" />
<span className="font-medium text-sm">{t('Delete')}</span>
</div>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={deleteSelectedMessages}
disabled={selectedMessages.size === 0}
className="text-xs"
>
{t('Selected')} ({selectedMessages.size})
</Button>
<Button
variant="destructive"
size="sm"
onClick={deleteAllInConversation}
className="text-xs"
>
{t('All')}
</Button>
</>
) : (
// Normal header
<>
<UserAvatar userId={currentConversation} className="size-8" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm truncate">{displayName}</span>
{isFollowing && (
<span title="Following">
<Users className="size-3 text-primary" />
</span>
)}
</div>
{profile?.nip05 && (
<span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
)}
</div>
{profile?.nip05 && (
<span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-8"
title={t('Reload messages')}
onClick={reloadConversation}
disabled={isLoadingConversation}
>
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
title={t('Conversation settings')}
onClick={() => {
setShowPulse(false)
clearNewConversationFlag()
setSettingsOpen(true)
}}
>
<Settings className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreVertical className="size-4" />
<Button
variant="ghost"
size="icon"
className="size-8"
title={t('Reload messages')}
onClick={reloadConversation}
disabled={isLoadingConversation}
>
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
title={t('Conversation settings')}
onClick={() => {
setShowPulse(false)
clearNewConversationFlag()
setSettingsOpen(true)
}}
>
<Settings className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
<Trash2 className="size-4 mr-2" />
{t('Delete All')}
</DropdownMenuItem>
<DropdownMenuItem onClick={undeleteAllInConversation}>
<Undo2 className="size-4 mr-2" />
{t('Undelete All')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onBack && (
<Button
variant="ghost"
size="icon"
className="size-8"
title={t('Close conversation')}
onClick={onBack}
>
<X className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
<Trash2 className="size-4 mr-2" />
{t('Delete All')}
</DropdownMenuItem>
<DropdownMenuItem onClick={undeleteAllInConversation}>
<Undo2 className="size-4 mr-2" />
{t('Undelete All')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
)}
</>
)}
</div>
)}
{/* Messages */}
<div className="flex-1 relative overflow-hidden">
@@ -288,12 +331,26 @@ export default function MessageView({ onBack }: MessageViewProps) {
</div>
) : (
<div className="space-y-3">
{/* Load more button at top */}
{hasMoreMessages && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
onClick={loadMoreMessages}
className="text-xs text-muted-foreground"
>
<ChevronUp className="size-4 mr-1" />
{t('Load older messages')} ({messages.length - visibleLimit} more)
</Button>
</div>
)}
{isLoadingConversation && (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{messages.map((message) => {
{visibleMessages.map((message) => {
const isOwn = message.senderPubkey === pubkey
const isSelected = selectedMessages.has(message.id)
return (
@@ -328,7 +385,11 @@ export default function MessageView({ onBack }: MessageViewProps) {
isSelected && 'ring-2 ring-primary ring-offset-2'
)}
>
<p className="text-sm whitespace-pre-wrap break-words">{message.content}</p>
<MessageContent
content={message.content}
className="text-sm"
isOwnMessage={isOwn}
/>
<div
className={cn(
'flex items-center justify-between gap-2 mt-1 text-xs',

View File

@@ -3,10 +3,13 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Separator } from '@/components/ui/separator'
import SocialGraphFilter from '@/components/SocialGraphFilter'
import { ExtendedKind } from '@/constants'
import { cn } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
import { ListFilter } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
@@ -34,22 +37,35 @@ const ALL_KINDS = KIND_FILTER_OPTIONS.flatMap(({ kindGroup }) => kindGroup)
export default function KindFilter({
showKinds,
onShowKindsChange
onShowKindsChange,
showSocialGraphFilter = false
}: {
showKinds: number[]
onShowKindsChange: (kinds: number[]) => void
showSocialGraphFilter?: boolean
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { showKinds: savedShowKinds } = useKindFilter()
const {
proximityLevel: savedProximity,
includeMode: savedIncludeMode,
updateProximityLevel,
updateIncludeMode
} = useSocialGraphFilter()
const [open, setOpen] = useState(false)
const { updateShowKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [temporaryProximity, setTemporaryProximity] = useState<number | null>(savedProximity)
const [temporaryIncludeMode, setTemporaryIncludeMode] = useState(savedIncludeMode)
const [isPersistent, setIsPersistent] = useState(false)
const isDifferentFromSaved = useMemo(
() => !isSameKindFilter(showKinds, savedShowKinds),
[showKinds, savedShowKinds]
)
const isDifferentFromSaved = useMemo(() => {
const kindsDifferent = !isSameKindFilter(showKinds, savedShowKinds)
const proximityDifferent = showSocialGraphFilter && savedProximity !== null
return kindsDifferent || proximityDifferent
}, [showKinds, savedShowKinds, savedProximity, showSocialGraphFilter])
const isTemporaryDifferentFromSaved = useMemo(
() => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
[temporaryShowKinds, savedShowKinds]
@@ -57,8 +73,10 @@ export default function KindFilter({
useEffect(() => {
setTemporaryShowKinds(showKinds)
setTemporaryProximity(savedProximity)
setTemporaryIncludeMode(savedIncludeMode)
setIsPersistent(false)
}, [open])
}, [open, savedProximity, savedIncludeMode])
const handleApply = () => {
if (temporaryShowKinds.length === 0) {
@@ -71,6 +89,16 @@ export default function KindFilter({
onShowKindsChange(newShowKinds)
}
// Apply social graph filter changes
if (showSocialGraphFilter) {
if (temporaryProximity !== savedProximity) {
updateProximityLevel(temporaryProximity)
}
if (temporaryIncludeMode !== savedIncludeMode) {
updateIncludeMode(temporaryIncludeMode)
}
}
if (isPersistent) {
updateShowKinds(newShowKinds)
}
@@ -155,6 +183,18 @@ export default function KindFilter({
</Button>
</div>
{showSocialGraphFilter && (
<>
<Separator className="my-4" />
<SocialGraphFilter
temporaryProximity={temporaryProximity}
temporaryIncludeMode={temporaryIncludeMode}
onTemporaryProximityChange={setTemporaryProximity}
onTemporaryIncludeModeChange={setTemporaryIncludeMode}
/>
</>
)}
<Label className="flex items-center gap-2 cursor-pointer mt-4">
<Checkbox
id="persistent-filter"

View File

@@ -0,0 +1,409 @@
/**
* NRC Settings Component
*
* UI for managing Nostr Relay Connect (NRC) connections and listener settings.
*/
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNRC } from '@/providers/NRCProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@/components/ui/alert-dialog'
import {
Link2,
Plus,
Trash2,
Copy,
Check,
QrCode,
Wifi,
WifiOff,
Users,
Server
} from 'lucide-react'
import { NRCConnection } from '@/services/nrc'
import QRCode from 'qrcode'
export default function NRCSettings() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const {
isEnabled,
isConnected,
connections,
activeSessions,
rendezvousUrl,
enable,
disable,
addConnection,
removeConnection,
getConnectionURI,
setRendezvousUrl
} = useNRC()
const [newConnectionLabel, setNewConnectionLabel] = useState('')
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isQRDialogOpen, setIsQRDialogOpen] = useState(false)
const [currentQRConnection, setCurrentQRConnection] = useState<NRCConnection | null>(null)
const [currentQRUri, setCurrentQRUri] = useState('')
const [qrDataUrl, setQrDataUrl] = useState('')
const [copiedUri, setCopiedUri] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Generate QR code when URI changes
const generateQRCode = useCallback(async (uri: string) => {
try {
const dataUrl = await QRCode.toDataURL(uri, {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#ffffff' }
})
setQrDataUrl(dataUrl)
} catch (error) {
console.error('Failed to generate QR code:', error)
}
}, [])
const handleToggleEnabled = useCallback(async () => {
if (isEnabled) {
disable()
} else {
setIsLoading(true)
try {
await enable()
} catch (error) {
console.error('Failed to enable NRC:', error)
} finally {
setIsLoading(false)
}
}
}, [isEnabled, enable, disable])
const handleAddConnection = useCallback(async () => {
if (!newConnectionLabel.trim()) return
setIsLoading(true)
try {
const { uri, connection } = await addConnection(newConnectionLabel.trim())
setIsAddDialogOpen(false)
setNewConnectionLabel('')
// Show QR code
setCurrentQRConnection(connection)
setCurrentQRUri(uri)
await generateQRCode(uri)
setIsQRDialogOpen(true)
} catch (error) {
console.error('Failed to add connection:', error)
} finally {
setIsLoading(false)
}
}, [newConnectionLabel, addConnection])
const handleShowQR = useCallback(
async (connection: NRCConnection) => {
try {
const uri = getConnectionURI(connection)
setCurrentQRConnection(connection)
setCurrentQRUri(uri)
await generateQRCode(uri)
setIsQRDialogOpen(true)
} catch (error) {
console.error('Failed to get connection URI:', error)
}
},
[getConnectionURI, generateQRCode]
)
const handleCopyUri = useCallback(async () => {
try {
await navigator.clipboard.writeText(currentQRUri)
setCopiedUri(true)
setTimeout(() => setCopiedUri(false), 2000)
} catch (error) {
console.error('Failed to copy URI:', error)
}
}, [currentQRUri])
const handleRemoveConnection = useCallback(
async (id: string) => {
try {
await removeConnection(id)
} catch (error) {
console.error('Failed to remove connection:', error)
}
},
[removeConnection]
)
if (!pubkey) {
return (
<div className="text-muted-foreground text-sm">
{t('Login required to use NRC')}
</div>
)
}
return (
<div className="space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="nrc-enabled" className="text-base font-medium">
{t('Enable Relay Connect')}
</Label>
<p className="text-sm text-muted-foreground">
{t('Allow other devices to sync with this client')}
</p>
</div>
<Switch
id="nrc-enabled"
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
disabled={isLoading}
/>
</div>
{/* Status Indicator */}
{isEnabled && (
<div className="flex items-center gap-4 p-3 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
{isConnected ? (
<Wifi className="w-4 h-4 text-green-500" />
) : (
<WifiOff className="w-4 h-4 text-yellow-500" />
)}
<span className="text-sm">
{isConnected ? t('Connected') : t('Connecting...')}
</span>
</div>
{activeSessions > 0 && (
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span className="text-sm">
{activeSessions} {t('active session(s)')}
</span>
</div>
)}
</div>
)}
{/* Rendezvous Relay */}
<div className="space-y-2">
<Label htmlFor="rendezvous-url" className="flex items-center gap-2">
<Server className="w-4 h-4" />
{t('Rendezvous Relay')}
</Label>
<Input
id="rendezvous-url"
value={rendezvousUrl}
onChange={(e) => setRendezvousUrl(e.target.value)}
placeholder="wss://relay.example.com"
disabled={isEnabled}
/>
{isEnabled && (
<p className="text-xs text-muted-foreground">
{t('Disable NRC to change the relay')}
</p>
)}
</div>
{/* Connections List */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Link2 className="w-4 h-4" />
{t('Authorized Devices')}
</Label>
<Button
variant="outline"
size="sm"
onClick={() => setIsAddDialogOpen(true)}
className="gap-1"
>
<Plus className="w-4 h-4" />
{t('Add')}
</Button>
</div>
{connections.length === 0 ? (
<div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
{t('No devices connected yet')}
</div>
) : (
<div className="space-y-2">
{connections.map((connection) => (
<div
key={connection.id}
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{connection.label}</div>
<div className="text-xs text-muted-foreground">
{new Date(connection.createdAt).toLocaleDateString()}
{connection.useCat && (
<span className="ml-2 px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px]">
CAT
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleShowQR(connection)}
title={t('Show QR Code')}
>
<QrCode className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
title={t('Remove')}
>
<Trash2 className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Remove Device?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('This will revoke access for "{{label}}". The device will no longer be able to sync.', {
label: connection.label
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleRemoveConnection(connection.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('Remove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
</div>
{/* Add Connection Dialog */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Add Device')}</DialogTitle>
<DialogDescription>
{t('Create a connection URI to link another device')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="device-label">{t('Device Name')}</Label>
<Input
id="device-label"
value={newConnectionLabel}
onChange={(e) => setNewConnectionLabel(e.target.value)}
placeholder={t('e.g., Phone, Laptop')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddConnection()
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
{t('Cancel')}
</Button>
<Button
onClick={handleAddConnection}
disabled={!newConnectionLabel.trim() || isLoading}
>
{t('Create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* QR Code Dialog */}
<Dialog open={isQRDialogOpen} onOpenChange={setIsQRDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('Connection QR Code')}</DialogTitle>
<DialogDescription>
{currentQRConnection && (
<>
{t('Scan this code with "{{label}}" to connect', {
label: currentQRConnection.label
})}
</>
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
{qrDataUrl && (
<div className="p-4 bg-white rounded-lg">
<img src={qrDataUrl} alt="Connection QR Code" className="w-64 h-64" />
</div>
)}
<div className="w-full">
<div className="flex items-center gap-2">
<Input
value={currentQRUri}
readOnly
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
onClick={handleCopyUri}
title={t('Copy')}
>
{copiedUri ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setIsQRDialogOpen(false)}>{t('Done')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -57,10 +57,11 @@ export default function NewNotesButton({
))}
</div>
)}
<span className="text-xs opacity-70"></span>
<ArrowUp />
<div className="text-md font-medium">
{t('Show n new notes', { n: newEvents.length > 99 ? '99+' : newEvents.length })}
</div>
<ArrowUp />
</Button>
</div>
)}

View File

@@ -16,6 +16,7 @@ export default function NormalFeed({
isMainFeed = false,
showRelayCloseReason = false,
disable24hMode = false,
enableSocialGraphFilter = false,
onRefresh
}: {
subRequests: TFeedSubRequest[]
@@ -23,6 +24,7 @@ export default function NormalFeed({
isMainFeed?: boolean
showRelayCloseReason?: boolean
disable24hMode?: boolean
enableSocialGraphFilter?: boolean
onRefresh?: () => void
}) {
const { hideUntrustedNotes } = useUserTrust()
@@ -87,6 +89,7 @@ export default function NormalFeed({
<KindFilter
showKinds={temporaryShowKinds}
onShowKindsChange={handleShowKindsChange}
showSocialGraphFilter={enableSocialGraphFilter}
/>
)}
</>
@@ -110,6 +113,7 @@ export default function NormalFeed({
hideUntrustedNotes={hideUntrustedNotes}
areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason}
applySocialGraphFilter={enableSocialGraphFilter}
/>
)}
</>

View File

@@ -1,7 +1,7 @@
import { Pubkey } from '@/domain'
import { useFetchEvent } from '@/hooks'
import { createFakeEvent } from '@/lib/event'
import { toNote } from '@/lib/link'
import { isValidPubkey } from '@/lib/pubkey'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
@@ -95,7 +95,7 @@ function HighlightSource({ event }: { event: Event }) {
}
if (sourceTag && sourceTag[0] === 'a') {
const [, pubkey] = sourceTag[1].split(':')
if (isValidPubkey(pubkey)) {
if (Pubkey.isValidHex(pubkey)) {
return pubkey
}
}

View File

@@ -1,7 +1,9 @@
import { Separator } from '@/components/ui/separator'
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
import { Event } from 'nostr-tools'
import Collapsible from '../Collapsible'
import Note from '../Note'
@@ -15,7 +17,9 @@ export default function MainNoteCard({
reposters,
embedded,
originalNoteId,
pinned = false
pinned = false,
navColumn,
navIndex
}: {
event: Event
className?: string
@@ -23,12 +27,18 @@ export default function MainNoteCard({
embedded?: boolean
originalNoteId?: string
pinned?: boolean
navColumn?: TNavigationColumn
navIndex?: number
}) {
const { push } = useSecondaryPage()
const { ref, isSelected } = useKeyboardNavigable(navColumn ?? 1, navIndex ?? 0, {
meta: { type: 'note', event }
})
return (
<div
className={className}
ref={ref}
className={cn(className, 'scroll-mt-[6.5rem]', isSelected && 'ring-2 ring-primary ring-inset')}
onClick={(e) => {
e.stopPropagation()
push(toNote(originalNoteId ?? event))

View File

@@ -1,6 +1,7 @@
import { isMentioningMutedUsers } from '@/lib/event'
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import client from '@/services/client.service'
import { Event, kinds, verifyEvent } from 'nostr-tools'
@@ -12,13 +13,17 @@ export default function RepostNoteCard({
className,
filterMutedNotes = true,
pinned = false,
reposters
reposters,
navColumn,
navIndex
}: {
event: Event
className?: string
filterMutedNotes?: boolean
pinned?: boolean
reposters?: string[]
navColumn?: TNavigationColumn
navIndex?: number
}) {
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
@@ -92,6 +97,8 @@ export default function RepostNoteCard({
reposters={reposters?.includes(event.pubkey) ? reposters : [event.pubkey]}
event={targetEvent}
pinned={pinned}
navColumn={navColumn}
navIndex={navIndex}
/>
)
}

View File

@@ -3,6 +3,7 @@ import { NSFW_DISPLAY_POLICY } from '@/constants'
import { isMentioningMutedUsers, isNsfwEvent } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
@@ -14,13 +15,17 @@ export default function NoteCard({
className,
filterMutedNotes = true,
pinned = false,
reposters
reposters,
navColumn,
navIndex
}: {
event: Event
className?: string
filterMutedNotes?: boolean
pinned?: boolean
reposters?: string[]
navColumn?: TNavigationColumn
navIndex?: number
}) {
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers, nsfwDisplayPolicy } = useContentPolicy()
@@ -46,10 +51,21 @@ export default function NoteCard({
filterMutedNotes={filterMutedNotes}
pinned={pinned}
reposters={reposters}
navColumn={navColumn}
navIndex={navIndex}
/>
)
}
return <MainNoteCard event={event} className={className} pinned={pinned} reposters={reposters} />
return (
<MainNoteCard
event={event}
className={className}
pinned={pinned}
reposters={reposters}
navColumn={navColumn}
navIndex={navIndex}
/>
)
}
export function NoteCardLoadingSkeleton({ className }: { className?: string }) {

View File

@@ -10,12 +10,12 @@ import RepostList from '../RepostList'
import ZapList from '../ZapList'
import { Tabs, TTabValue } from './Tabs'
export default function NoteInteractions({ event }: { event: Event }) {
export default function NoteInteractions({ event, navIndexOffset = 0 }: { event: Event; navIndexOffset?: number }) {
const [type, setType] = useState<TTabValue>('replies')
let list
switch (type) {
case 'replies':
list = <ReplyNoteList stuff={event} />
list = <ReplyNoteList stuff={event} navIndexOffset={navIndexOffset} />
break
case 'quotes':
list = <QuoteList stuff={event} />

View File

@@ -6,8 +6,10 @@ import { tagNameEquals } from '@/lib/tag'
import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import threadService from '@/services/thread.service'
@@ -53,6 +55,8 @@ const NoteList = forwardRef<
pinnedEventIds?: string[]
filterFn?: (event: Event) => boolean
showNewNotesDirectly?: boolean
navColumn?: TNavigationColumn
applySocialGraphFilter?: boolean
}
>(
(
@@ -67,7 +71,9 @@ const NoteList = forwardRef<
showRelayCloseReason = false,
pinnedEventIds,
filterFn,
showNewNotesDirectly = false
showNewNotesDirectly = false,
navColumn = 1,
applySocialGraphFilter = false
},
ref
) => {
@@ -77,6 +83,8 @@ const NoteList = forwardRef<
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const { isPubkeyAllowed } = useSocialGraphFilter()
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [initialLoading, setInitialLoading] = useState(false)
@@ -118,10 +126,22 @@ const NoteList = forwardRef<
if (filterFn && !filterFn(evt)) {
return true
}
// Social graph filter - only apply if enabled for this feed
if (applySocialGraphFilter && !isPubkeyAllowed(evt.pubkey)) {
return true
}
return false
},
[hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn]
[
hideUntrustedNotes,
mutePubkeySet,
JSON.stringify(pinnedEventIds),
isEventDeleted,
filterFn,
applySocialGraphFilter,
isPubkeyAllowed
]
)
useEffect(() => {
@@ -366,24 +386,47 @@ const NoteList = forwardRef<
initialLoading
})
const showNewEvents = () => {
// Register load more callback for keyboard navigation
useEffect(() => {
registerLoadMore(navColumn, handleLoadMore)
return () => unregisterLoadMore(navColumn)
}, [navColumn, handleLoadMore, registerLoadMore, unregisterLoadMore])
const showNewEvents = useCallback(() => {
if (filteredNewEvents.length === 0) return
// Offset the selection by the number of new items being added at the top
offsetSelection(navColumn, filteredNewEvents.length)
setEvents((oldEvents) => [...newEvents, ...oldEvents])
setNewEvents([])
setTimeout(() => {
scrollToTop('smooth')
}, 0)
}
}, [filteredNewEvents.length, navColumn, newEvents, offsetSelection])
// Shift+Enter to show new notes
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.shiftKey && e.key === 'Enter' && filteredNewEvents.length > 0) {
e.preventDefault()
showNewEvents()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [showNewEvents, filteredNewEvents.length])
const list = (
<div className="min-h-screen">
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
{visibleItems.map(({ key, event, reposters }) => (
{visibleItems.map(({ key, event, reposters }, index) => (
<NoteCard
key={key}
className="w-full"
event={event}
filterMutedNotes={filterMutedNotes}
reposters={reposters}
navColumn={navColumn}
navIndex={index}
/>
))}
<div ref={bottomRef} />

View File

@@ -1,6 +1,6 @@
import { Pubkey } from '@/domain'
import { getNoteBech32Id, isProtectedEvent } from '@/lib/event'
import { toNjump } from '@/lib/link'
import { pubkeyToNpub } from '@/lib/pubkey'
import { simplifyUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@@ -174,7 +174,7 @@ export function useMenuActions({
icon: Copy,
label: t('Copy user ID'),
onClick: () => {
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')
navigator.clipboard.writeText(Pubkey.tryFromString(event.pubkey)?.npub ?? '')
closeDrawer()
}
},

View File

@@ -5,10 +5,12 @@ import Notification from './Notification'
export function HighlightNotification({
notification,
isNew = false
isNew = false,
navIndex
}: {
notification: Event
isNew?: boolean
navIndex?: number
}) {
const { t } = useTranslation()
@@ -21,6 +23,7 @@ export function HighlightNotification({
targetEvent={notification}
description={t('highlighted your note')}
isNew={isNew}
navIndex={navIndex}
/>
)
}

View File

@@ -13,10 +13,12 @@ import Notification from './Notification'
export function MentionNotification({
notification,
isNew = false
isNew = false,
navIndex
}: {
notification: Event
isNew?: boolean
navIndex?: number
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
@@ -68,6 +70,7 @@ export function MentionNotification({
}
isNew={isNew}
showStats
navIndex={navIndex}
/>
)
}

View File

@@ -5,6 +5,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { NOTIFICATION_LIST_STYLE } from '@/constants'
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
import { toNote, toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
@@ -24,7 +25,8 @@ export default function Notification({
middle = null,
targetEvent,
isNew = false,
showStats = false
showStats = false,
navIndex
}: {
icon: React.ReactNode
notificationId: string
@@ -35,6 +37,7 @@ export default function Notification({
targetEvent?: NostrEvent
isNew?: boolean
showStats?: boolean
navIndex?: number
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
@@ -46,6 +49,10 @@ export default function Notification({
[isNew, isNotificationRead, notificationId]
)
const { ref: navRef, isSelected } = useKeyboardNavigable(1, navIndex ?? 0, {
meta: { type: 'note' }
})
const handleClick = () => {
markNotificationAsRead(notificationId)
if (targetEvent) {
@@ -58,7 +65,11 @@ export default function Notification({
if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) {
return (
<div
className="flex items-center justify-between cursor-pointer py-2 px-4"
ref={navRef}
className={cn(
'flex items-center justify-between cursor-pointer py-2 px-4 scroll-mt-[6.5rem]',
isSelected && 'ring-2 ring-primary ring-inset'
)}
onClick={handleClick}
>
<div className="flex gap-2 items-center flex-1 w-0">
@@ -84,7 +95,11 @@ export default function Notification({
return (
<div
className="clickable flex items-start gap-2 cursor-pointer py-2 px-4 border-b"
ref={navRef}
className={cn(
'clickable flex items-start gap-2 cursor-pointer py-2 px-4 border-b scroll-mt-[6.5rem]',
isSelected && 'ring-2 ring-primary ring-inset'
)}
onClick={handleClick}
>
<div className="flex gap-2 items-center mt-1.5">

View File

@@ -8,10 +8,12 @@ import { useTranslation } from 'react-i18next'
export function PollResponseNotification({
notification,
isNew = false
isNew = false,
navIndex
}: {
notification: Event
isNew?: boolean
navIndex?: number
}) {
const { t } = useTranslation()
const eventId = useMemo(() => {
@@ -33,6 +35,7 @@ export function PollResponseNotification({
targetEvent={pollEvent}
description={t('voted in your poll')}
isNew={isNew}
navIndex={navIndex}
/>
)
}

View File

@@ -10,10 +10,12 @@ import Notification from './Notification'
export function ReactionNotification({
notification,
isNew = false
isNew = false,
navIndex
}: {
notification: Event
isNew?: boolean
navIndex?: number
}) {
const { t } = useTranslation()
const { pubkey } = useNostr()
@@ -66,6 +68,7 @@ export function ReactionNotification({
targetEvent={event}
description={t('reacted to your note')}
isNew={isNew}
navIndex={navIndex}
/>
)
}

View File

@@ -7,10 +7,12 @@ import Notification from './Notification'
export function RepostNotification({
notification,
isNew = false
isNew = false,
navIndex
}: {
notification: Event
isNew?: boolean
navIndex?: number
}) {
const { t } = useTranslation()
const event = useMemo(() => {
@@ -35,6 +37,7 @@ export function RepostNotification({
targetEvent={event}
description={t('reposted your note')}
isNew={isNew}
navIndex={navIndex}
/>
)
}

View File

@@ -9,10 +9,12 @@ import Notification from './Notification'
export function ZapNotification({
notification,
isNew = false
isNew = false,
navIndex
}: {
notification: Event
isNew?: boolean
navIndex?: number
}) {
const { t } = useTranslation()
const { senderPubkey, eventId, amount, comment } = useMemo(
@@ -37,6 +39,7 @@ export function ZapNotification({
}
description={event ? t('zapped your note') : t('zapped you')}
isNew={isNew}
navIndex={navIndex}
/>
)
}

View File

@@ -15,10 +15,12 @@ import { ZapNotification } from './ZapNotification'
export function NotificationItem({
notification,
isNew = false
isNew = false,
navIndex
}: {
notification: Event
isNew?: boolean
navIndex?: number
}) {
const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList()
@@ -42,7 +44,7 @@ export function NotificationItem({
if (!canShow) return null
if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} isNew={isNew} />
return <ReactionNotification notification={notification} isNew={isNew} navIndex={navIndex} />
}
if (
notification.kind === kinds.ShortTextNote ||
@@ -50,19 +52,19 @@ export function NotificationItem({
notification.kind === ExtendedKind.VOICE_COMMENT ||
notification.kind === ExtendedKind.POLL
) {
return <MentionNotification notification={notification} isNew={isNew} />
return <MentionNotification notification={notification} isNew={isNew} navIndex={navIndex} />
}
if (notification.kind === kinds.Repost || notification.kind === kinds.GenericRepost) {
return <RepostNotification notification={notification} isNew={isNew} />
return <RepostNotification notification={notification} isNew={isNew} navIndex={navIndex} />
}
if (notification.kind === kinds.Zap) {
return <ZapNotification notification={notification} isNew={isNew} />
return <ZapNotification notification={notification} isNew={isNew} navIndex={navIndex} />
}
if (notification.kind === ExtendedKind.POLL_RESPONSE) {
return <PollResponseNotification notification={notification} isNew={isNew} />
return <PollResponseNotification notification={notification} isNew={isNew} navIndex={navIndex} />
}
if (notification.kind === kinds.Highlights) {
return <HighlightNotification notification={notification} isNew={isNew} />
return <HighlightNotification notification={notification} isNew={isNew} navIndex={navIndex} />
}
return null
}

View File

@@ -254,11 +254,12 @@ const NotificationList = forwardRef((_, ref) => {
const list = (
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}>
{visibleNotifications.map((notification) => (
{visibleNotifications.map((notification, index) => (
<NotificationItem
key={notification.id}
notification={notification}
isNew={notification.created_at > lastReadTime}
navIndex={index}
/>
))}
<div className="text-center text-sm text-muted-foreground">

View File

@@ -3,22 +3,43 @@ import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { QrCodeIcon } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import { useMemo } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import Nip05 from '../Nip05'
import PubkeyCopy from '../PubkeyCopy'
import QrCode from '../QrCode'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
export default function NpubQrCode({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
const [open, setOpen] = useState(false)
const npub = useMemo(() => {
// Validate pubkey is a 64-character hex string before encoding
if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return ''
try {
return nip19.npubEncode(pubkey)
} catch {
return ''
}
}, [pubkey])
const handleQrClick = useCallback(() => {
navigator.clipboard.writeText(npub)
toast.success(t('Copied npub to clipboard'))
setOpen(false)
}, [npub, t])
if (!npub) return null
const trigger = (
<div className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground">
<button
className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<QrCodeIcon size={14} />
</div>
</button>
)
const content = (
@@ -26,29 +47,33 @@ export default function NpubQrCode({ pubkey }: { pubkey: string }) {
<div className="flex items-center w-full gap-2 pointer-events-none px-1">
<UserAvatar size="big" userId={pubkey} />
<div className="flex-1 w-0">
<Username userId={pubkey} className="text-2xl font-semibold truncate" />
<Username userId={pubkey} className="text-2xl font-semibold truncate" showQrCode={false} />
<Nip05 pubkey={pubkey} />
</div>
</div>
<QrCode size={512} value={`nostr:${npub}`} />
<div className="flex flex-col items-center">
<PubkeyCopy pubkey={pubkey} />
</div>
<button
onClick={handleQrClick}
className="cursor-pointer hover:opacity-90 transition-opacity"
title={t('Click to copy npub')}
>
<QrCode size={512} value={`nostr:${npub}`} />
</button>
<div className="text-sm text-muted-foreground">{t('Click QR code to copy npub')}</div>
</div>
)
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger>{trigger}</DrawerTrigger>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent>{content}</DrawerContent>
</Drawer>
)
}
return (
<Dialog>
<DialogTrigger>{trigger}</DialogTrigger>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="w-80 p-0 m-0" onOpenAutoFocus={(e) => e.preventDefault()}>
{content}
</DialogContent>

View File

@@ -1,8 +1,8 @@
import { useSecondaryPage } from '@/PageManager'
import { Badge } from '@/components/ui/badge'
import { Pubkey } from '@/domain'
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
import { toRelay } from '@/lib/link'
import { userIdToPubkey } from '@/lib/pubkey'
import { TMailboxRelay } from '@/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -10,7 +10,7 @@ import RelaySimpleInfo from '../RelaySimpleInfo'
export default function OthersRelayList({ userId }: { userId: string }) {
const { t } = useTranslation()
const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
const pubkey = useMemo(() => Pubkey.tryFromString(userId)?.hex ?? userId, [userId])
const { relayList, isFetching } = useFetchRelayList(pubkey)
if (isFetching) {

View File

@@ -1,6 +1,6 @@
import FollowingBadge from '@/components/FollowingBadge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
import { Pubkey } from '@/domain'
import { cn } from '@/lib/utils'
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
@@ -24,7 +24,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
const item = props.items[index]
if (item) {
props.command({ id: item, label: formatNpub(item) })
props.command({ id: item, label: Pubkey.tryFromString(item)?.formatNpub(12) ?? item.slice(0, 12) })
}
}
@@ -92,7 +92,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
<SimpleUsername userId={item} className="font-semibold truncate" />
<FollowingBadge userId={item} />
</div>
<Nip05 pubkey={userIdToPubkey(item)} />
<Nip05 pubkey={Pubkey.tryFromString(item)?.hex ?? item} />
</div>
</div>
</button>

View File

@@ -1,6 +1,6 @@
import TextWithEmojis from '@/components/TextWithEmojis'
import { Pubkey } from '@/domain'
import { useFetchProfile } from '@/hooks'
import { formatUserId } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
@@ -15,7 +15,7 @@ export default function MentionNode(props: NodeViewRendererProps & { selected: b
{profile ? (
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
) : (
formatUserId(props.node.attrs.id)
Pubkey.tryFromString(props.node.attrs.id)?.formatNpub(12) ?? props.node.attrs.id.slice(0, 12)
)}
</NodeViewWrapper>
)

View File

@@ -1,4 +1,4 @@
import { formatNpub } from '@/lib/pubkey'
import { Pubkey } from '@/domain'
import TTMention from '@tiptap/extension-mention'
import { ReactNodeViewRenderer } from '@tiptap/react'
import MentionNode from './MentionNode'
@@ -34,7 +34,7 @@ const Mention = TTMention.extend({
type: 'mention',
attrs: {
id: npub,
label: formatNpub(npub)
label: Pubkey.tryFromString(npub)?.formatNpub(12) ?? npub.slice(0, 12)
}
},
{

View File

@@ -1,7 +1,9 @@
import UserAvatar from '@/components/UserAvatar'
import { BIG_RELAY_URLS } from '@/constants'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import graphQueryService from '@/services/graph-query.service'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -15,6 +17,55 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
if (!pubkey || !accountPubkey) return
const init = async () => {
const limit = isSmallScreen ? 3 : 5
// Try graph query first for depth-2 follows
const graphResult = await graphQueryService.queryFollowGraph(
BIG_RELAY_URLS,
accountPubkey,
2
)
if (graphResult?.pubkeys_by_depth && graphResult.pubkeys_by_depth.length >= 2) {
// Use graph query results - much more efficient
const directFollows = new Set(graphResult.pubkeys_by_depth[0] ?? [])
// Check which of user's follows also follow the target pubkey
const _followedBy: string[] = []
// We need to check if target pubkey is in each direct follow's follow list
// The graph query gives us all follows of follows at depth 2,
// but we need to know *which* direct follow has the target in their follows
// For now, we'll still need to do individual checks but can optimize with caching
// Alternative approach: Use followers query on the target
const followerResult = await graphQueryService.queryFollowerGraph(
BIG_RELAY_URLS,
pubkey,
1
)
if (followerResult?.pubkeys_by_depth?.[0]) {
// Followers of target pubkey
const targetFollowers = new Set(followerResult.pubkeys_by_depth[0])
// Find which of user's follows are followers of the target
for (const following of directFollows) {
if (following === pubkey) continue
if (targetFollowers.has(following)) {
_followedBy.push(following)
if (_followedBy.length >= limit) break
}
}
}
if (_followedBy.length > 0) {
setFollowedBy(_followedBy)
return
}
}
// Fallback to traditional method
const followings = (await client.fetchFollowings(accountPubkey)).reverse()
const followingsOfFollowings = await Promise.all(
followings.map(async (following) => {
@@ -22,7 +73,6 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
})
)
const _followedBy: string[] = []
const limit = isSmallScreen ? 3 : 5
for (const [index, following] of followings.entries()) {
if (following === pubkey) continue
if (followingsOfFollowings[index].includes(pubkey)) {
@@ -35,7 +85,7 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
setFollowedBy(_followedBy)
}
init()
}, [pubkey, accountPubkey])
}, [pubkey, accountPubkey, isSmallScreen])
if (followedBy.length === 0) return null

View File

@@ -1,5 +1,5 @@
import { Pubkey } from '@/domain'
import { useFetchProfile } from '@/hooks'
import { userIdToPubkey } from '@/lib/pubkey'
import { useMemo } from 'react'
import FollowButton from '../FollowButton'
import Nip05 from '../Nip05'
@@ -9,7 +9,7 @@ import TrustScoreBadge from '../TrustScoreBadge'
import { SimpleUserAvatar } from '../UserAvatar'
export default function ProfileCard({ userId }: { userId: string }) {
const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
const pubkey = useMemo(() => Pubkey.tryFromString(userId)?.hex ?? userId, [userId])
const { profile } = useFetchProfile(userId)
const { username, about, emojis } = profile || {}

View File

@@ -6,7 +6,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { pubkeyToNpub } from '@/lib/pubkey'
import { Pubkey } from '@/domain'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -50,7 +50,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
<Button
onClick={() => {
setIsDrawerOpen(false)
navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')
navigator.clipboard.writeText(Pubkey.tryFromString(pubkey)?.npub ?? '')
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
@@ -109,7 +109,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(Pubkey.tryFromString(pubkey)?.npub ?? '')}>
<Copy />
{t('Copy user ID')}
</DropdownMenuItem>

View File

@@ -1,10 +1,10 @@
import { formatNpub } from '@/lib/pubkey'
import { Pubkey } from '@/domain'
import { Check, Copy } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import { useMemo, useState } from 'react'
export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
const pk = useMemo(() => Pubkey.tryFromString(pubkey), [pubkey])
const npub = pk?.npub ?? ''
const [copied, setCopied] = useState(false)
const copyNpub = () => {
@@ -20,7 +20,7 @@ export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full clickable"
onClick={() => copyNpub()}
>
<div>{formatNpub(npub, 24)}</div>
<div>{pk?.formatNpub(24) ?? npub}</div>
{copied ? <Check size={14} /> : <Copy size={14} />}
</div>
)

View File

@@ -0,0 +1,71 @@
import { Button } from '@/components/ui/button'
import { X } from 'lucide-react'
import QrScanner from 'qr-scanner'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function QrScannerModal({
onScan,
onClose
}: {
onScan: (result: string) => void
onClose: () => void
}) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement>(null)
const scannerRef = useRef<QrScanner | null>(null)
const [error, setError] = useState<string | null>(null)
const handleScan = useCallback(
(result: QrScanner.ScanResult) => {
onScan(result.data)
onClose()
},
[onScan, onClose]
)
useEffect(() => {
if (!videoRef.current) return
const scanner = new QrScanner(videoRef.current, handleScan, {
preferredCamera: 'environment',
highlightScanRegion: true,
highlightCodeOutline: true
})
scannerRef.current = scanner
scanner.start().catch(() => {
setError(t('Failed to access camera'))
})
return () => {
scanner.destroy()
}
}, [handleScan, t])
return (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
<div className="relative w-full max-w-sm mx-4">
<Button
variant="ghost"
size="icon"
className="absolute -top-12 right-0 text-white hover:bg-white/20"
onClick={onClose}
>
<X className="h-6 w-6" />
</Button>
<div className="rounded-lg overflow-hidden bg-black">
{error ? (
<div className="p-8 text-center text-destructive">{error}</div>
) : (
<video ref={videoRef} className="w-full" />
)}
</div>
<p className="text-center text-white/70 text-sm mt-4">
{t('Point camera at QR code')}
</p>
</div>
</div>
)
}

View File

@@ -1,11 +1,13 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
import { useThread } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
@@ -29,13 +31,17 @@ export default function ReplyNote({
parentEventId,
onClickParent = () => {},
highlight = false,
className = ''
className = '',
navColumn,
navIndex
}: {
event: Event
parentEventId?: string
onClickParent?: () => void
highlight?: boolean
className?: string
navColumn?: TNavigationColumn
navIndex?: number
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
@@ -46,6 +52,13 @@ export default function ReplyNote({
const eventKey = useMemo(() => getEventKey(event), [event])
const replies = useThread(eventKey)
const [showMuted, setShowMuted] = useState(false)
// Keyboard navigation
const { ref: navRef, isSelected } = useKeyboardNavigable(
navColumn ?? 2,
navIndex ?? 0,
{ meta: { type: 'note', event } }
)
const show = useMemo(() => {
if (showMuted) {
return true
@@ -79,9 +92,11 @@ export default function ReplyNote({
return (
<div
ref={navRef}
className={cn(
'relative pb-3 transition-colors duration-500 clickable',
'relative pb-3 transition-colors duration-500 clickable scroll-mt-[6.5rem]',
highlight ? 'bg-primary/40' : '',
isSelected && 'ring-2 ring-primary ring-inset',
className
)}
onClick={() => push(toNote(event))}

View File

@@ -1,4 +1,5 @@
import { useSecondaryPage } from '@/PageManager'
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link'
@@ -13,8 +14,15 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReplyNote from '../ReplyNote'
export default function SubReplies({ parentKey }: { parentKey: string }) {
const { t } = useTranslation()
export default function SubReplies({
parentKey,
revealerNavIndex,
subReplyNavIndexStart
}: {
parentKey: string
revealerNavIndex?: number
subReplyNavIndexStart?: number
}) {
const { push } = useSecondaryPage()
const allThreads = useAllDescendantThreads(parentKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
@@ -86,37 +94,12 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
return (
<div>
{replies.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
className="relative w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable"
>
<div
className={cn('absolute left-[34px] top-0 bottom-0 w-px text-border z-20')}
style={{
background: isExpanded
? 'currentColor'
: 'repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 7px)'
}}
/>
{isExpanded ? (
<>
<ChevronUp className="size-3.5" />
<span>
{t('Hide replies')} ({replies.length})
</span>
</>
) : (
<>
<ChevronDown className="size-3.5" />
<span>
{t('Show replies')} ({replies.length})
</span>
</>
)}
</button>
<Revealer
isExpanded={isExpanded}
onToggle={() => setIsExpanded((prev) => !prev)}
replyCount={replies.length}
navIndex={revealerNavIndex}
/>
)}
{(isExpanded || replies.length === 1) && (
<div>
@@ -139,6 +122,8 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
<ReplyNote
className="flex-1 w-0 pl-10"
event={reply}
navColumn={2}
navIndex={subReplyNavIndexStart !== undefined ? subReplyNavIndexStart + index : undefined}
parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
onClickParent={() => {
if (!_parentKey) return
@@ -154,3 +139,60 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
</div>
)
}
function Revealer({
isExpanded,
onToggle,
replyCount,
navIndex
}: {
isExpanded: boolean
onToggle: () => void
replyCount: number
navIndex?: number
}) {
const { t } = useTranslation()
const { ref: revealerRef, isSelected } = useKeyboardNavigable(2, navIndex ?? 0, {
meta: { type: 'note', onActivate: onToggle }
})
return (
<div ref={revealerRef} className="scroll-mt-[6.5rem]">
<button
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
className={cn(
'relative w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable',
isSelected && 'ring-2 ring-primary ring-inset'
)}
>
<div
className={cn('absolute left-[34px] top-0 bottom-0 w-px text-border z-20')}
style={{
background: isExpanded
? 'currentColor'
: 'repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 7px)'
}}
/>
{isExpanded ? (
<>
<ChevronUp className="size-3.5" />
<span>
{t('Hide replies')} ({replyCount})
</span>
</>
) : (
<>
<ChevronDown className="size-3.5" />
<span>
{t('Show replies')} ({replyCount})
</span>
</>
)}
</button>
</div>
)
}

View File

@@ -16,7 +16,7 @@ import SubReplies from './SubReplies'
const LIMIT = 100
const SHOW_COUNT = 10
export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
export default function ReplyNoteList({ stuff, navIndexOffset = 0 }: { stuff: NEvent | string; navIndexOffset?: number }) {
const { t } = useTranslation()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
@@ -90,8 +90,8 @@ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
<div className="min-h-[80vh]">
{(loading || initialLoading) && <LoadingBar />}
<div>
{visibleItems.map((reply) => (
<Item key={reply.id} reply={reply} />
{visibleItems.map((reply, index) => (
<Item key={reply.id} reply={reply} navIndex={navIndexOffset + index} />
))}
</div>
<div ref={bottomRef} />
@@ -106,13 +106,17 @@ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
)
}
function Item({ reply }: { reply: NEvent }) {
// Use larger gaps between items to leave room for sub-replies
const NAV_INDEX_MULTIPLIER = 100
function Item({ reply, navIndex }: { reply: NEvent; navIndex: number }) {
const key = useMemo(() => getEventKey(reply), [reply])
const baseNavIndex = navIndex * NAV_INDEX_MULTIPLIER
return (
<div className="relative border-b">
<ReplyNote event={reply} />
<SubReplies parentKey={key} />
<ReplyNote event={reply} navColumn={2} navIndex={baseNavIndex} />
<SubReplies parentKey={key} revealerNavIndex={baseNavIndex + 1} subReplyNavIndexStart={baseNavIndex + 2} />
</div>
)
}

View File

@@ -342,6 +342,27 @@ const SearchBar = forwardRef<
onKeyDown={handleKeyDown}
onFocus={() => setSearching(true)}
onBlur={() => setSearching(false)}
onQrScan={(value) => {
setInput(value)
// Automatically search after scanning
let id = value
if (id.startsWith('nostr:')) {
id = id.slice(6)
}
try {
const { type } = nip19.decode(id)
if (['nprofile', 'npub'].includes(type)) {
updateSearch({ type: 'profile', search: id })
return
}
if (['nevent', 'naddr', 'note'].includes(type)) {
updateSearch({ type: 'note', search: id })
return
}
} catch {
// Not a valid nip19 identifier, just set input
}
}}
/>
</div>
)

View File

@@ -1,11 +1,17 @@
import { cn } from '@/lib/utils'
import { SearchIcon, X } from 'lucide-react'
import { QrCodeIcon, SearchIcon, X } from 'lucide-react'
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
import QrScannerModal from '../QrScannerModal'
const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
({ value, onChange, className, ...props }, ref) => {
type SearchInputProps = ComponentProps<'input'> & {
onQrScan?: (value: string) => void
}
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ value, onChange, className, onQrScan, ...props }, ref) => {
const [displayClear, setDisplayClear] = useState(false)
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
const [showQrScanner, setShowQrScanner] = useState(false)
useEffect(() => {
setDisplayClear(!!value)
@@ -20,34 +26,55 @@ const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
}
}
const handleQrScan = (result: string) => {
// Strip nostr: prefix if present
const value = result.startsWith('nostr:') ? result.slice(6) : result
onQrScan?.(value)
}
return (
<div
tabIndex={0}
className={cn(
'flex h-9 w-full items-center rounded-xl border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-all duration-200 md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-2 [&:has(:focus-visible)]:outline-none hover:border-ring/50',
className
<>
<div
tabIndex={0}
className={cn(
'flex h-9 w-full items-center rounded-xl border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-all duration-200 md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-2 [&:has(:focus-visible)]:outline-none hover:border-ring/50',
className
)}
>
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
<input
{...props}
name="search-input"
ref={setRefs}
value={value}
onChange={onChange}
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
/>
{onQrScan && (
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors size-5 shrink-0 flex items-center justify-center mr-1"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setShowQrScanner(true)}
>
<QrCodeIcon className="size-4" />
</button>
)}
{displayClear && (
<button
type="button"
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChange?.({ target: { value: '' } } as any)}
>
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
</button>
)}
</div>
{showQrScanner && (
<QrScannerModal onScan={handleQrScan} onClose={() => setShowQrScanner(false)} />
)}
>
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
<input
{...props}
name="search-input"
ref={setRefs}
value={value}
onChange={onChange}
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
/>
{displayClear && (
<button
type="button"
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChange?.({ target: { value: '' } } as any)}
>
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
</button>
)}
</div>
</>
)
}
)

View File

@@ -1,10 +1,12 @@
import AboutInfoDialog from '@/components/AboutInfoDialog'
import QrScannerModal from '@/components/QrScannerModal'
import Donation from '@/components/Donation'
import Emoji from '@/components/Emoji'
import EmojiPackList from '@/components/EmojiPackList'
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import MailboxSetting from '@/components/MailboxSetting'
import NRCSettings from '@/components/NRCSettings'
import NoteList from '@/components/NoteList'
import Tabs from '@/components/Tabs'
import {
@@ -54,7 +56,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
import { useZap } from '@/providers/ZapProvider'
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
import { connectNWC, disconnect, launchModal } from '@getalby/bitcoin-connect-react'
import {
Check,
Cog,
@@ -71,6 +73,8 @@ import {
PanelLeft,
PencilLine,
RotateCcw,
ScanLine,
RefreshCw,
Server,
Settings2,
Smile,
@@ -78,8 +82,10 @@ import {
Wallet
} from 'lucide-react'
import { kinds } from 'nostr-tools'
import { forwardRef, HTMLProps, useCallback, useState } from 'react'
import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useKeyboardNavigation, useNavigationRegion, NavigationIntent } from '@/providers/KeyboardNavigationProvider'
import { usePrimaryPage } from '@/PageManager'
type TEmojiTab = 'my-packs' | 'explore'
@@ -100,6 +106,9 @@ const NOTIFICATION_STYLES = [
{ key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
] as const
// Accordion item values for keyboard navigation
const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'sync', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system']
export default function Settings() {
const { t, i18n } = useTranslation()
const { pubkey, nsec, ncryptsec } = useNostr()
@@ -107,6 +116,98 @@ export default function Settings() {
const [copiedNsec, setCopiedNsec] = useState(false)
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
const [openSection, setOpenSection] = useState<string>('')
const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1)
const accordionRefs = useRef<(HTMLDivElement | null)[]>([])
const { activeColumn, scrollToCenter } = useKeyboardNavigation()
const { current: currentPage } = usePrimaryPage()
// Get the visible accordion items based on pubkey availability
const visibleAccordionItems = pubkey
? ACCORDION_ITEMS
: ACCORDION_ITEMS.filter((item) => !['sync', 'wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
// Register as a navigation region - Settings decides what "up/down" means
const handleSettingsIntent = useCallback(
(intent: NavigationIntent): boolean => {
switch (intent) {
case 'up':
setSelectedAccordionIndex((prev) => {
const newIndex = prev <= 0 ? 0 : prev - 1
setTimeout(() => {
const el = accordionRefs.current[newIndex]
if (el) scrollToCenter(el)
}, 0)
return newIndex
})
return true
case 'down':
setSelectedAccordionIndex((prev) => {
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
setTimeout(() => {
const el = accordionRefs.current[newIndex]
if (el) scrollToCenter(el)
}, 0)
return newIndex
})
return true
case 'activate':
if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
const value = visibleAccordionItems[selectedAccordionIndex]
setOpenSection((prev) => (prev === value ? '' : value))
return true
}
return false
case 'cancel':
if (openSection) {
setOpenSection('')
return true
}
return false
default:
return false
}
},
[selectedAccordionIndex, openSection, visibleAccordionItems, scrollToCenter]
)
// Register this component as a navigation region when it's active
useNavigationRegion(
'settings-accordion',
100, // High priority - handle intents before default handlers
() => activeColumn === 1 && currentPage === 'settings', // Only active when settings is displayed
handleSettingsIntent,
[handleSettingsIntent, activeColumn, currentPage]
)
// Reset selection when column changes
useEffect(() => {
if (activeColumn !== 1) {
setSelectedAccordionIndex(-1)
}
}, [activeColumn])
// Helper to get accordion index and check selection
const getAccordionIndex = useCallback(
(value: string) => visibleAccordionItems.indexOf(value),
[visibleAccordionItems]
)
const isAccordionSelected = useCallback(
(value: string) => selectedAccordionIndex === getAccordionIndex(value),
[selectedAccordionIndex, getAccordionIndex]
)
const setAccordionRef = useCallback((value: string) => (el: HTMLDivElement | null) => {
const idx = visibleAccordionItems.indexOf(value)
if (idx !== -1) {
accordionRefs.current[idx] = el
}
}, [visibleAccordionItems])
// General settings
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
@@ -155,10 +256,21 @@ export default function Settings() {
// System settings
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
const [graphQueriesEnabled, setGraphQueriesEnabled] = useState(storage.getGraphQueriesEnabled())
// Messaging settings
const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
// Wallet QR scanner
const [showWalletScanner, setShowWalletScanner] = useState(false)
const handleWalletScan = useCallback((result: string) => {
// Check if it's a valid NWC URI
if (result.startsWith('nostr+walletconnect://')) {
connectNWC(result)
}
}, [])
const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value)
setLanguage(value)
@@ -183,13 +295,14 @@ export default function Settings() {
className="w-full"
>
{/* General */}
<AccordionItem value="general">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Settings2 className="size-4" />
<span>{t('General')}</span>
</div>
</AccordionTrigger>
<NavigableAccordionItem ref={setAccordionRef('general')} isSelected={isAccordionSelected('general')}>
<AccordionItem value="general">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Settings2 className="size-4" />
<span>{t('General')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 space-y-4">
<SettingItem>
<Label htmlFor="languages" className="text-base font-normal">
@@ -331,10 +444,12 @@ export default function Settings() {
</SettingItem>
)}
</AccordionContent>
</AccordionItem>
</AccordionItem>
</NavigableAccordionItem>
{/* Appearance */}
<AccordionItem value="appearance">
<NavigableAccordionItem ref={setAccordionRef('appearance')} isSelected={isAccordionSelected('appearance')}>
<AccordionItem value="appearance">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Palette className="size-4" />
@@ -406,10 +521,12 @@ export default function Settings() {
</div>
</div>
</AccordionContent>
</AccordionItem>
</AccordionItem>
</NavigableAccordionItem>
{/* Relays */}
<AccordionItem value="relays">
<NavigableAccordionItem ref={setAccordionRef('relays')} isSelected={isAccordionSelected('relays')}>
<AccordionItem value="relays">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Server className="size-4" />
@@ -430,11 +547,30 @@ export default function Settings() {
</TabsContent>
</RadixTabs>
</AccordionContent>
</AccordionItem>
</AccordionItem>
</NavigableAccordionItem>
{/* Sync (NRC) */}
{!!pubkey && (
<NavigableAccordionItem ref={setAccordionRef('sync')} isSelected={isAccordionSelected('sync')}>
<AccordionItem value="sync">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<RefreshCw className="size-4" />
<span>{t('Device Sync')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4">
<NRCSettings />
</AccordionContent>
</AccordionItem>
</NavigableAccordionItem>
)}
{/* Wallet */}
{!!pubkey && (
<AccordionItem value="wallet">
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
<AccordionItem value="wallet">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Wallet className="size-4" />
@@ -476,34 +612,54 @@ export default function Settings() {
<LightningAddressInput />
</>
) : (
<div className="flex items-center gap-2">
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
{t('Connect Wallet')}
</Button>
</div>
<>
{showWalletScanner && (
<QrScannerModal
onScan={handleWalletScan}
onClose={() => setShowWalletScanner(false)}
/>
)}
<div className="flex items-center gap-2">
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
{t('Connect Wallet')}
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setShowWalletScanner(true)}
title={t('Scan NWC QR code')}
>
<ScanLine className="h-4 w-4" />
</Button>
</div>
</>
)}
</AccordionContent>
</AccordionItem>
</AccordionItem>
</NavigableAccordionItem>
)}
{/* Post Settings */}
{!!pubkey && (
<AccordionItem value="posts">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<PencilLine className="size-4" />
<span>{t('Post settings')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4">
<MediaUploadServiceSetting />
</AccordionContent>
</AccordionItem>
<NavigableAccordionItem ref={setAccordionRef('posts')} isSelected={isAccordionSelected('posts')}>
<AccordionItem value="posts">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<PencilLine className="size-4" />
<span>{t('Post settings')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4">
<MediaUploadServiceSetting />
</AccordionContent>
</AccordionItem>
</NavigableAccordionItem>
)}
{/* Emoji Packs */}
{!!pubkey && (
<AccordionItem value="emoji-packs">
<NavigableAccordionItem ref={setAccordionRef('emoji-packs')} isSelected={isAccordionSelected('emoji-packs')}>
<AccordionItem value="emoji-packs">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Smile className="size-4" />
@@ -529,45 +685,49 @@ export default function Settings() {
/>
)}
</AccordionContent>
</AccordionItem>
</AccordionItem>
</NavigableAccordionItem>
)}
{/* Messaging */}
{!!pubkey && (
<AccordionItem value="messaging">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<MessageSquare className="size-4" />
<span>{t('Messaging')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 space-y-4">
<SettingItem>
<Label htmlFor="prefer-nip44" className="text-base font-normal">
<div>{t('Prefer NIP-44 encryption')}</div>
<div className="text-muted-foreground text-sm">
{t('Use modern encryption for new conversations')}
</div>
</Label>
<Switch
id="prefer-nip44"
checked={preferNip44}
onCheckedChange={(checked) => {
storage.setPreferNip44(checked)
setPreferNip44(checked)
dispatchSettingsChanged()
}}
/>
</SettingItem>
</AccordionContent>
</AccordionItem>
<NavigableAccordionItem ref={setAccordionRef('messaging')} isSelected={isAccordionSelected('messaging')}>
<AccordionItem value="messaging">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<MessageSquare className="size-4" />
<span>{t('Messaging')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 space-y-4">
<SettingItem>
<Label htmlFor="prefer-nip44" className="text-base font-normal">
<div>{t('Prefer NIP-44 encryption')}</div>
<div className="text-muted-foreground text-sm">
{t('Use modern encryption for new conversations')}
</div>
</Label>
<Switch
id="prefer-nip44"
checked={preferNip44}
onCheckedChange={(checked) => {
storage.setPreferNip44(checked)
setPreferNip44(checked)
dispatchSettingsChanged()
}}
/>
</SettingItem>
</AccordionContent>
</AccordionItem>
</NavigableAccordionItem>
)}
{/* System */}
<AccordionItem value="system">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Cog className="size-4" />
<NavigableAccordionItem ref={setAccordionRef('system')} isSelected={isAccordionSelected('system')}>
<AccordionItem value="system">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Cog className="size-4" />
<span>{t('System')}</span>
</div>
</AccordionTrigger>
@@ -598,8 +758,28 @@ export default function Settings() {
}}
/>
</SettingItem>
<SettingItem>
<div>
<Label htmlFor="graph-queries-enabled" className="text-base font-normal">
{t('Graph query optimization')}
</Label>
<p className="text-sm text-muted-foreground">
{t('Use graph queries for faster follow/thread loading on supported relays')}
</p>
</div>
<Switch
id="graph-queries-enabled"
checked={graphQueriesEnabled}
onCheckedChange={(checked) => {
storage.setGraphQueriesEnabled(checked)
setGraphQueriesEnabled(checked)
dispatchSettingsChanged()
}}
/>
</SettingItem>
</AccordionContent>
</AccordionItem>
</AccordionItem>
</NavigableAccordionItem>
</Accordion>
{/* Non-accordion items */}
@@ -697,3 +877,25 @@ const OptionButton = ({
</button>
)
}
// Wrapper for keyboard-navigable accordion items
const NavigableAccordionItem = forwardRef<
HTMLDivElement,
{
isSelected: boolean
children: React.ReactNode
}
>(({ isSelected, children }, ref) => {
return (
<div
ref={ref}
className={cn(
'rounded-lg transition-all',
isSelected && 'ring-2 ring-primary ring-offset-2 ring-offset-background'
)}
>
{children}
</div>
)
})
NavigableAccordionItem.displayName = 'NavigableAccordionItem'

View File

@@ -1,18 +1,28 @@
import { usePrimaryPage } from '@/PageManager'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Bookmark } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function BookmarkButton({ collapse }: { collapse: boolean }) {
export default function BookmarkButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
const { navigate, current, display } = usePrimaryPage()
const { checkLogin } = useNostr()
const { clearColumn } = useKeyboardNavigation()
const handleClick = () => {
checkLogin(() => {
navigate('bookmark')
clearColumn(1)
})
}
return (
<SidebarItem
title="Bookmarks"
onClick={() => checkLogin(() => navigate('bookmark'))}
onClick={handleClick}
active={display && current === 'bookmark'}
collapse={collapse}
navIndex={navIndex}
>
<Bookmark />
</SidebarItem>

View File

@@ -0,0 +1,34 @@
import { toHelp } from '@/lib/link'
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { HelpCircle } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function HelpButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
const { current, navigate, display } = usePrimaryPage()
const { push } = useSecondaryPage()
const { enableSingleColumnLayout } = useUserPreferences()
const { clearColumn } = useKeyboardNavigation()
const handleClick = () => {
if (enableSingleColumnLayout) {
navigate('help')
clearColumn(1)
} else {
push(toHelp())
}
}
return (
<SidebarItem
title="Help"
onClick={handleClick}
collapse={collapse}
active={display && current === 'help'}
navIndex={navIndex}
>
<HelpCircle />
</SidebarItem>
)
}

View File

@@ -1,16 +1,25 @@
import { usePrimaryPage } from '@/PageManager'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { Home } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function HomeButton({ collapse }: { collapse: boolean }) {
export default function HomeButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
const { navigate, current, display } = usePrimaryPage()
const { resetPrimarySelection, clearColumn } = useKeyboardNavigation()
const handleClick = () => {
navigate('home')
clearColumn(1)
resetPrimarySelection()
}
return (
<SidebarItem
title="Home"
onClick={() => navigate('home')}
onClick={handleClick}
active={display && current === 'home'}
collapse={collapse}
navIndex={navIndex}
>
<Home />
</SidebarItem>

View File

@@ -1,18 +1,26 @@
import { usePrimaryPage } from '@/PageManager'
import { useDM } from '@/providers/DMProvider'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { MessageSquare } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function InboxButton({ collapse }: { collapse: boolean }) {
export default function InboxButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
const { navigate, current, display } = usePrimaryPage()
const { hasNewMessages } = useDM()
const { clearColumn } = useKeyboardNavigation()
const handleClick = () => {
navigate('inbox')
clearColumn(1)
}
return (
<SidebarItem
title="Inbox"
onClick={() => navigate('inbox')}
onClick={handleClick}
active={display && current === 'inbox'}
collapse={collapse}
navIndex={navIndex}
>
<div className="relative">
<MessageSquare />

View File

@@ -0,0 +1,36 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { Keyboard } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function KeyboardModeButton({ collapse }: { collapse: boolean }) {
const { t } = useTranslation()
const { isEnabled, toggleKeyboardMode } = useKeyboardNavigation()
return (
<Button
className={cn(
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-2 text-sm font-semibold',
collapse
? 'w-12 h-12 p-3 [&_svg]:size-full'
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
isEnabled && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10'
)}
variant="ghost"
title={t('Toggle keyboard navigation (⇧K)')}
onClick={toggleKeyboardMode}
>
<Keyboard />
{!collapse && (
<div className="flex items-center gap-2">
<span>{t('Keyboard')}</span>
<kbd className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground border">K</kbd>
</div>
)}
{collapse && (
<span className="sr-only">{t('Toggle keyboard navigation')}</span>
)}
</Button>
)
}

View File

@@ -1,20 +1,30 @@
import { usePrimaryPage } from '@/PageManager'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationProvider'
import { Bell } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function NotificationsButton({ collapse }: { collapse: boolean }) {
export default function NotificationsButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
const { checkLogin } = useNostr()
const { navigate, current, display } = usePrimaryPage()
const { hasNewNotification } = useNotification()
const { clearColumn } = useKeyboardNavigation()
const handleClick = () => {
checkLogin(() => {
navigate('notifications')
clearColumn(1)
})
}
return (
<SidebarItem
title="Notifications"
onClick={() => checkLogin(() => navigate('notifications'))}
onClick={handleClick}
active={display && current === 'notifications'}
collapse={collapse}
navIndex={navIndex}
>
<div className="relative">
<Bell />

View File

@@ -5,7 +5,7 @@ import { PencilLine } from 'lucide-react'
import { useState } from 'react'
import SidebarItem from './SidebarItem'
export default function PostButton({ collapse }: { collapse: boolean }) {
export default function PostButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
const { checkLogin } = useNostr()
const [open, setOpen] = useState(false)
@@ -23,6 +23,7 @@ export default function PostButton({ collapse }: { collapse: boolean }) {
variant="default"
className={cn('bg-primary gap-2', !collapse && 'justify-center')}
collapse={collapse}
navIndex={navIndex}
>
<PencilLine />
</SidebarItem>

View File

@@ -1,18 +1,28 @@
import { usePrimaryPage } from '@/PageManager'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function ProfileButton({ collapse }: { collapse: boolean }) {
export default function ProfileButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
const { navigate, current, display } = usePrimaryPage()
const { checkLogin } = useNostr()
const { clearColumn } = useKeyboardNavigation()
const handleClick = () => {
checkLogin(() => {
navigate('profile')
clearColumn(1)
})
}
return (
<SidebarItem
title="Profile"
onClick={() => checkLogin(() => navigate('profile'))}
onClick={handleClick}
active={display && current === 'profile'}
collapse={collapse}
navIndex={navIndex}
>
<UserRound />
</SidebarItem>

View File

@@ -1,16 +1,24 @@
import { usePrimaryPage } from '@/PageManager'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { Search } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function SearchButton({ collapse }: { collapse: boolean }) {
export default function SearchButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
const { navigate, current, display } = usePrimaryPage()
const { clearColumn } = useKeyboardNavigation()
const handleClick = () => {
navigate('search')
clearColumn(1)
}
return (
<SidebarItem
title="Search"
onClick={() => navigate('search')}
onClick={handleClick}
active={current === 'search' && display}
collapse={collapse}
navIndex={navIndex}
>
<Search />
</SidebarItem>

View File

@@ -1,20 +1,32 @@
import { toSettings } from '@/lib/link'
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { Settings } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function SettingsButton({ collapse }: { collapse: boolean }) {
export default function SettingsButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
const { current, navigate, display } = usePrimaryPage()
const { push } = useSecondaryPage()
const { enableSingleColumnLayout } = useUserPreferences()
const { clearColumn } = useKeyboardNavigation()
const handleClick = () => {
if (enableSingleColumnLayout) {
navigate('settings')
clearColumn(1)
} else {
push(toSettings())
}
}
return (
<SidebarItem
title="Settings"
onClick={() => (enableSingleColumnLayout ? navigate('settings') : push(toSettings()))}
onClick={handleClick}
collapse={collapse}
active={display && current === 'settings'}
navIndex={navIndex}
>
<Settings />
</SidebarItem>

View File

@@ -1,32 +1,52 @@
import { Button, ButtonProps } from '@/components/ui/button'
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
import { cn } from '@/lib/utils'
import { forwardRef } from 'react'
import { forwardRef, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
const SidebarItem = forwardRef<
HTMLButtonElement,
ButtonProps & { title: string; collapse: boolean; description?: string; active?: boolean }
>(({ children, title, description, className, active, collapse, ...props }, ref) => {
ButtonProps & {
title: string
collapse: boolean
description?: string
active?: boolean
navIndex?: number
}
>(({ children, title, description, className, active, collapse, navIndex, onClick, ...props }, _ref) => {
const { t } = useTranslation()
const buttonRef = useRef<HTMLButtonElement>(null)
const handleActivate = useCallback(() => {
buttonRef.current?.click()
}, [])
const { ref: navRef, isSelected } = useKeyboardNavigable(0, navIndex ?? 0, {
meta: { type: 'sidebar', onActivate: handleActivate }
})
return (
<Button
className={cn(
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-4 text-lg font-semibold',
collapse
? 'w-12 h-12 p-3 [&_svg]:size-full'
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
active && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10',
className
)}
variant="ghost"
title={t(title)}
ref={ref}
{...props}
>
{children}
{!collapse && <div>{t(description ?? title)}</div>}
</Button>
<div ref={navRef}>
<Button
className={cn(
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-4 text-lg font-semibold',
collapse
? 'w-12 h-12 p-3 [&_svg]:size-full'
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
active && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10',
isSelected && 'ring-2 ring-primary ring-offset-2 ring-offset-background',
className
)}
variant="ghost"
title={t(title)}
ref={buttonRef}
onClick={onClick}
{...props}
>
{children}
{!collapse && <div>{t(description ?? title)}</div>}
</Button>
</div>
)
})
SidebarItem.displayName = 'SidebarItem'

View File

@@ -9,8 +9,10 @@ import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { ChevronsLeft, ChevronsRight } from 'lucide-react'
import AccountButton from './AccountButton'
import BookmarkButton from './BookmarkButton'
import HelpButton from './HelpButton'
import HomeButton from './HomeButton'
import InboxButton from './InboxButton'
import KeyboardModeButton from './KeyboardModeButton'
import LayoutSwitcher from './LayoutSwitcher'
import NotificationsButton from './NotificationButton'
import PostButton from './PostButton'
@@ -55,16 +57,18 @@ export default function PrimaryPageSidebar() {
<Logo />
</button>
)}
<HomeButton collapse={isCollapsed} />
<NotificationsButton collapse={isCollapsed} />
<SearchButton collapse={isCollapsed} />
{pubkey && <InboxButton collapse={isCollapsed} />}
<ProfileButton collapse={isCollapsed} />
{pubkey && <BookmarkButton collapse={isCollapsed} />}
<SettingsButton collapse={isCollapsed} />
<PostButton collapse={isCollapsed} />
<HomeButton collapse={isCollapsed} navIndex={0} />
<NotificationsButton collapse={isCollapsed} navIndex={1} />
<SearchButton collapse={isCollapsed} navIndex={2} />
{pubkey && <InboxButton collapse={isCollapsed} navIndex={3} />}
<ProfileButton collapse={isCollapsed} navIndex={pubkey ? 4 : 3} />
{pubkey && <BookmarkButton collapse={isCollapsed} navIndex={5} />}
<SettingsButton collapse={isCollapsed} navIndex={pubkey ? 6 : 4} />
<PostButton collapse={isCollapsed} navIndex={pubkey ? 7 : 5} />
</div>
<div className="space-y-4">
<HelpButton collapse={isCollapsed} navIndex={pubkey ? 8 : 6} />
<KeyboardModeButton collapse={isCollapsed} />
<LayoutSwitcher collapse={isCollapsed} />
<AccountButton collapse={isCollapsed} />
</div>

View File

@@ -0,0 +1,127 @@
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
import { Loader2, Minus, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
const DEPTH_LABELS: Record<number, string> = {
1: 'Direct follows',
2: 'Follows of follows'
}
interface SocialGraphFilterProps {
temporaryProximity: number | null
temporaryIncludeMode: boolean
onTemporaryProximityChange: (level: number | null) => void
onTemporaryIncludeModeChange: (include: boolean) => void
}
export default function SocialGraphFilter({
temporaryProximity,
temporaryIncludeMode,
onTemporaryProximityChange,
onTemporaryIncludeModeChange
}: SocialGraphFilterProps) {
const { t } = useTranslation()
const { graphPubkeyCount, isLoading } = useSocialGraphFilter()
const isEnabled = temporaryProximity !== null
const depth = temporaryProximity ?? 1
const handleToggle = (enabled: boolean) => {
onTemporaryProximityChange(enabled ? 1 : null)
}
const handleIncrease = () => {
if (depth < 2) {
onTemporaryProximityChange(depth + 1)
}
}
const handleDecrease = () => {
if (depth > 1) {
onTemporaryProximityChange(depth - 1)
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="social-graph-filter" className="font-medium">
{t('Social graph filter')}
</Label>
<Switch id="social-graph-filter" checked={isEnabled} onCheckedChange={handleToggle} />
</div>
{isEnabled && (
<>
{/* Include/Exclude toggle */}
<div className="flex items-center gap-2">
<Button
variant={temporaryIncludeMode ? 'default' : 'outline'}
size="sm"
className="flex-1"
onClick={() => onTemporaryIncludeModeChange(true)}
>
{t('Include')}
</Button>
<Button
variant={!temporaryIncludeMode ? 'default' : 'outline'}
size="sm"
className="flex-1"
onClick={() => onTemporaryIncludeModeChange(false)}
>
{t('Exclude')}
</Button>
</div>
{/* Depth stepper */}
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div className="flex-1">
<p className="text-sm font-medium">{t(DEPTH_LABELS[depth])}</p>
<p className="text-xs text-muted-foreground">
{isLoading ? (
<span className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
{t('Loading...')}
</span>
) : (
t('{{count}} users', { count: graphPubkeyCount })
)}
</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleDecrease}
disabled={depth <= 1}
>
<Minus className="h-4 w-4" />
</Button>
<span className="w-6 text-center text-sm font-medium">{depth}</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleIncrease}
disabled={depth >= 2}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Mode description */}
<p className="text-xs text-muted-foreground">
{temporaryIncludeMode
? t('Only show notes from users in your social graph')
: t('Hide notes from users in your social graph')}
</p>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
export default function KeyboardShortcut({ shortcut }: { shortcut: string }) {
const { isEnabled } = useKeyboardNavigation()
if (!isEnabled) return null
return (
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">
{shortcut}
</kbd>
)
}

View File

@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji'
import EmojiPicker from '../EmojiPicker'
import SuggestedEmojis from '../SuggestedEmojis'
import KeyboardShortcut from './KeyboardShortcut'
import { formatCount } from './utils'
export default function LikeButton({ stuff }: { stuff: Event | string }) {
@@ -111,9 +112,10 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
const trigger = (
<button
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground"
title={t('Like')}
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground group"
title={t('React (Shift+R)')}
disabled={liking}
data-action="react"
onClick={handleClick}
onMouseDown={handleLongPressStart}
onMouseUp={handleLongPressEnd}
@@ -125,12 +127,18 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
<Loader className="animate-spin" />
) : myLastEmoji ? (
<>
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
<span className="relative">
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
<KeyboardShortcut shortcut="R" />
</span>
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
) : (
<>
<SmilePlus />
<span className="relative">
<SmilePlus />
<KeyboardShortcut shortcut="R" />
</span>
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
)}
@@ -181,6 +189,7 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
onMoreButtonClick={() => {
setIsPickerOpen(true)
}}
onClose={() => setIsEmojiReactionsOpen(false)}
/>
)}
</PopoverContent>

View File

@@ -11,6 +11,7 @@ import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import KeyboardShortcut from './KeyboardShortcut'
import { formatCount } from './utils'
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
@@ -56,7 +57,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
<>
<button
className={cn(
'flex gap-1 items-center enabled:hover:text-blue-400 pr-3 h-full',
'flex gap-1 items-center enabled:hover:text-blue-400 pr-3 h-full group',
hasReplied ? 'text-blue-400' : 'text-muted-foreground'
)}
onClick={(e) => {
@@ -65,9 +66,13 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
setOpen(true)
})
}}
title={t('Reply')}
title={t('Reply (r)')}
data-action="reply"
>
<MessageCircle />
<span className="relative">
<MessageCircle />
<KeyboardShortcut shortcut="r" />
</span>
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
</button>
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />

View File

@@ -20,6 +20,7 @@ import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import KeyboardShortcut from './KeyboardShortcut'
import { formatCount } from './utils'
export default function RepostButton({ stuff }: { stuff: Event | string }) {
@@ -77,11 +78,12 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
const trigger = (
<button
className={cn(
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40 group',
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)}
disabled={!event}
title={t('Repost')}
title={t('Repost (p) / Quote (q)')}
data-action="repost"
onClick={() => {
if (!event) return
@@ -90,7 +92,10 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
}
}}
>
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
<span className="relative">
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
<KeyboardShortcut shortcut="p" />
</span>
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
</button>
)
@@ -107,10 +112,25 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
/>
)
// Hidden button for keyboard shortcut (q for quote)
const quoteButton = (
<button
className="hidden"
data-action="quote"
onClick={(e) => {
e.stopPropagation()
checkLogin(() => {
setIsPostDialogOpen(true)
})
}}
/>
)
if (isSmallScreen) {
return (
<>
{trigger}
{quoteButton}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
@@ -174,6 +194,7 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{quoteButton}
{postEditor}
</>
)

View File

@@ -14,6 +14,7 @@ import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 're
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import ZapDialog from '../ZapDialog'
import KeyboardShortcut from './KeyboardShortcut'
export default function ZapButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
@@ -135,22 +136,26 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
<>
<button
className={cn(
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default',
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default group',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)}
title={t('Zap')}
title={t('Zap (z)')}
disabled={disable || zapping}
data-action="zap"
onMouseDown={handleClickStart}
onMouseUp={handleClickEnd}
onMouseLeave={handleMouseLeave}
onTouchStart={handleClickStart}
onTouchEnd={handleClickEnd}
>
{zapping ? (
<Loader className="animate-spin" />
) : (
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
)}
<span className="relative">
{zapping ? (
<Loader className="animate-spin" />
) : (
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
)}
<KeyboardShortcut shortcut="z" />
</span>
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
</button>
{event && (

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