Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c3e8d5cc7 | ||
|
|
158f3d77d3 | ||
|
|
f54c73f0eb | ||
|
|
1d58162890 | ||
|
|
9820a1c6c0 | ||
|
|
ad5f9cccf9 |
1340
DDD_ANALYSIS.md
581
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -91,6 +91,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/uri-templates": "^0.1.34",
|
"@types/uri-templates": "^0.1.34",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
@@ -102,7 +103,8 @@
|
|||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"typescript-eslint": "^8.18.1",
|
"typescript-eslint": "^8.18.1",
|
||||||
"vite": "^6.0.3",
|
"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": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -1599,6 +1601,16 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@cashu/cashu-ts": {
|
||||||
"version": "2.5.2",
|
"version": "2.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.5.2.tgz",
|
||||||
@@ -2555,14 +2567,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.25",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
@@ -5301,6 +5315,13 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
"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"
|
"@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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -5812,6 +5844,13 @@
|
|||||||
"@types/ms": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"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"
|
"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": {
|
"node_modules/@webbtc/webln-types": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@webbtc/webln-types/-/webln-types-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@webbtc/webln-types/-/webln-types-3.0.0.tgz",
|
||||||
@@ -6330,6 +6495,45 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
@@ -6658,9 +6862,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001690",
|
"version": "1.0.30001762",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
|
||||||
"integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==",
|
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6675,7 +6879,8 @@
|
|||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/ccount": {
|
"node_modules/ccount": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@@ -6686,6 +6891,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -7799,6 +8014,13 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
|
||||||
@@ -8091,6 +8313,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -8676,6 +8908,13 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
"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": {
|
"node_modules/jackspeak": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
@@ -9598,6 +9891,47 @@
|
|||||||
"sourcemap-codec": "^1.4.8"
|
"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": {
|
"node_modules/markdown-it": {
|
||||||
"version": "14.1.0",
|
"version": "14.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||||
@@ -10663,6 +10997,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -10832,6 +11177,13 @@
|
|||||||
"node": ">=16"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -12019,6 +12371,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -12101,6 +12460,20 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
@@ -12510,6 +12883,23 @@
|
|||||||
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==",
|
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -12558,6 +12948,16 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/tippy.js": {
|
||||||
"version": "6.3.7",
|
"version": "6.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||||
@@ -13227,6 +13627,144 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/void-elements": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
@@ -13370,6 +13908,23 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.2.4",
|
"version": "0.3.1",
|
||||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -17,7 +17,10 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -102,6 +105,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/uri-templates": "^0.1.34",
|
"@types/uri-templates": "^0.1.34",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
@@ -113,6 +117,7 @@
|
|||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"typescript-eslint": "^8.18.1",
|
"typescript-eslint": "^8.18.1",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vite-plugin-pwa": "^0.21.1"
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 745 B |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 513 B |
22
resources/icon-apple-touch.svg
Normal 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 |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 513 B |
20
resources/icon-white.svg
Normal 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 |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 513 B |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
@@ -4,6 +4,7 @@ import './index.css'
|
|||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { BookmarksProvider } from '@/providers/BookmarksProvider'
|
import { BookmarksProvider } from '@/providers/BookmarksProvider'
|
||||||
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
|
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { EventHandlerProvider } from '@/providers/EventHandlerProvider'
|
||||||
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
|
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
|
||||||
import { DMProvider } from '@/providers/DMProvider'
|
import { DMProvider } from '@/providers/DMProvider'
|
||||||
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
|
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
|
||||||
@@ -17,6 +18,7 @@ import { NostrProvider } from '@/providers/NostrProvider'
|
|||||||
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
|
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
|
||||||
import { PinListProvider } from '@/providers/PinListProvider'
|
import { PinListProvider } from '@/providers/PinListProvider'
|
||||||
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
||||||
|
import { RepositoryProvider } from '@/providers/RepositoryProvider'
|
||||||
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
||||||
import { SettingsSyncProvider } from '@/providers/SettingsSyncProvider'
|
import { SettingsSyncProvider } from '@/providers/SettingsSyncProvider'
|
||||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||||
@@ -28,12 +30,14 @@ import { PageManager } from './PageManager'
|
|||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<ScreenSizeProvider>
|
<ScreenSizeProvider>
|
||||||
|
<EventHandlerProvider>
|
||||||
<UserPreferencesProvider>
|
<UserPreferencesProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ContentPolicyProvider>
|
<ContentPolicyProvider>
|
||||||
<DeletedEventProvider>
|
<DeletedEventProvider>
|
||||||
<PasswordPromptProvider>
|
<PasswordPromptProvider>
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
|
<RepositoryProvider>
|
||||||
<SettingsSyncProvider>
|
<SettingsSyncProvider>
|
||||||
<ZapProvider>
|
<ZapProvider>
|
||||||
<FavoriteRelaysProvider>
|
<FavoriteRelaysProvider>
|
||||||
@@ -64,12 +68,14 @@ export default function App(): JSX.Element {
|
|||||||
</FavoriteRelaysProvider>
|
</FavoriteRelaysProvider>
|
||||||
</ZapProvider>
|
</ZapProvider>
|
||||||
</SettingsSyncProvider>
|
</SettingsSyncProvider>
|
||||||
|
</RepositoryProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</PasswordPromptProvider>
|
</PasswordPromptProvider>
|
||||||
</DeletedEventProvider>
|
</DeletedEventProvider>
|
||||||
</ContentPolicyProvider>
|
</ContentPolicyProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</UserPreferencesProvider>
|
</UserPreferencesProvider>
|
||||||
|
</EventHandlerProvider>
|
||||||
</ScreenSizeProvider>
|
</ScreenSizeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import ActionModeOverlay from '@/components/ActionModeOverlay'
|
||||||
import Sidebar from '@/components/Sidebar'
|
import Sidebar from '@/components/Sidebar'
|
||||||
import SidebarDrawer from '@/components/SidebarDrawer'
|
import SidebarDrawer from '@/components/SidebarDrawer'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
|
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
|
||||||
|
import { KeyboardNavigationProvider } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { TPageRef } from '@/types'
|
import { TPageRef } from '@/types'
|
||||||
import {
|
import {
|
||||||
cloneElement,
|
cloneElement,
|
||||||
@@ -321,34 +323,42 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
<SidebarDrawerContext.Provider value={sidebarDrawerContext}>
|
<SidebarDrawerContext.Provider value={sidebarDrawerContext}>
|
||||||
<CurrentRelaysProvider>
|
<CurrentRelaysProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
{!!secondaryStack.length &&
|
<KeyboardNavigationProvider
|
||||||
secondaryStack.map((item, index) => (
|
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
|
<div
|
||||||
key={item.index}
|
key={name}
|
||||||
style={{
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{primaryPages.map(({ name, element, props }) => (
|
<SidebarDrawer
|
||||||
<div
|
open={sidebarDrawerOpen}
|
||||||
key={name}
|
onOpenChange={setSidebarDrawerOpen}
|
||||||
style={{
|
/>
|
||||||
display:
|
<TooManyRelaysAlertDialog />
|
||||||
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
|
<CreateWalletGuideToast />
|
||||||
}}
|
<ActionModeOverlay />
|
||||||
>
|
</KeyboardNavigationProvider>
|
||||||
{props ? cloneElement(element as React.ReactElement, props) : element}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<SidebarDrawer
|
|
||||||
open={sidebarDrawerOpen}
|
|
||||||
onOpenChange={setSidebarDrawerOpen}
|
|
||||||
/>
|
|
||||||
<TooManyRelaysAlertDialog />
|
|
||||||
<CreateWalletGuideToast />
|
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</CurrentRelaysProvider>
|
</CurrentRelaysProvider>
|
||||||
</SidebarDrawerContext.Provider>
|
</SidebarDrawerContext.Provider>
|
||||||
@@ -377,41 +387,49 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
>
|
>
|
||||||
<CurrentRelaysProvider>
|
<CurrentRelaysProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<div className="flex lg:justify-around w-full bg-chrome-background">
|
<KeyboardNavigationProvider
|
||||||
<div className="sticky top-0 lg:w-full flex justify-end self-start h-[var(--vh)]">
|
secondaryStackLength={secondaryStack.length}
|
||||||
<Sidebar />
|
sidebarDrawerOpen={false}
|
||||||
</div>
|
onBack={() => popSecondaryPage()}
|
||||||
<div className="flex-1 w-0 bg-background border-x lg:flex-auto lg:w-[640px] lg:shrink-0">
|
onCloseSecondary={() => clearSecondaryPages()}
|
||||||
{!!secondaryStack.length &&
|
>
|
||||||
secondaryStack.map((item, index) => (
|
<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
|
<div
|
||||||
key={item.index}
|
key={name}
|
||||||
style={{
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{primaryPages.map(({ name, element, props }) => (
|
</div>
|
||||||
<div
|
<div className="hidden lg:w-full lg:block" />
|
||||||
key={name}
|
|
||||||
style={{
|
|
||||||
display:
|
|
||||||
secondaryStack.length === 0 && currentPrimaryPage === name
|
|
||||||
? 'block'
|
|
||||||
: 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props ? cloneElement(element as React.ReactElement, props) : element}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:w-full lg:block" />
|
<TooManyRelaysAlertDialog />
|
||||||
</div>
|
<CreateWalletGuideToast />
|
||||||
<TooManyRelaysAlertDialog />
|
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
|
||||||
<CreateWalletGuideToast />
|
<ActionModeOverlay />
|
||||||
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
|
</KeyboardNavigationProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</CurrentRelaysProvider>
|
</CurrentRelaysProvider>
|
||||||
</SecondaryPageContext.Provider>
|
</SecondaryPageContext.Provider>
|
||||||
@@ -436,62 +454,70 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
>
|
>
|
||||||
<CurrentRelaysProvider>
|
<CurrentRelaysProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<div className="flex flex-col items-center bg-surface-background">
|
<KeyboardNavigationProvider
|
||||||
<div
|
secondaryStackLength={secondaryStack.length}
|
||||||
className="flex h-[var(--vh)] w-full bg-surface-background"
|
sidebarDrawerOpen={false}
|
||||||
style={{
|
onBack={() => popSecondaryPage()}
|
||||||
maxWidth: '1920px'
|
onCloseSecondary={() => clearSecondaryPages()}
|
||||||
}}
|
>
|
||||||
>
|
<div className="flex flex-col items-center bg-surface-background">
|
||||||
<Sidebar />
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="flex h-[var(--vh)] w-full bg-surface-background"
|
||||||
'grid grid-cols-2 w-full',
|
style={{
|
||||||
themeSetting === 'pure-black' ? '' : 'gap-2 pr-2 py-2'
|
maxWidth: '1920px'
|
||||||
)}
|
}}
|
||||||
>
|
>
|
||||||
|
<Sidebar />
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background overflow-hidden',
|
'grid grid-cols-2 w-full',
|
||||||
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl shadow-lg'
|
themeSetting === 'pure-black' ? '' : 'gap-2 pr-2 py-2'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{primaryPages.map(({ name, element, props }) => (
|
<div
|
||||||
<div
|
className={cn(
|
||||||
key={name}
|
'bg-background overflow-hidden',
|
||||||
className="flex flex-col h-full w-full"
|
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl shadow-lg'
|
||||||
style={{
|
)}
|
||||||
display: currentPrimaryPage === name ? 'block' : 'none'
|
>
|
||||||
}}
|
{primaryPages.map(({ name, element, props }) => (
|
||||||
>
|
<div
|
||||||
{props ? cloneElement(element as React.ReactElement, props) : element}
|
key={name}
|
||||||
</div>
|
className="flex flex-col h-full w-full"
|
||||||
))}
|
style={{
|
||||||
</div>
|
display: currentPrimaryPage === name ? 'block' : 'none'
|
||||||
<div
|
}}
|
||||||
className={cn(
|
>
|
||||||
'bg-background overflow-hidden',
|
{props ? cloneElement(element as React.ReactElement, props) : element}
|
||||||
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl',
|
</div>
|
||||||
themeSetting !== 'pure-black' && secondaryStack.length > 0 && 'shadow-lg',
|
))}
|
||||||
secondaryStack.length === 0 ? 'bg-surface' : ''
|
</div>
|
||||||
)}
|
<div
|
||||||
>
|
className={cn(
|
||||||
{secondaryStack.map((item, index) => (
|
'bg-background overflow-hidden',
|
||||||
<div
|
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl',
|
||||||
key={item.index}
|
themeSetting !== 'pure-black' && secondaryStack.length > 0 && 'shadow-lg',
|
||||||
className="flex flex-col h-full w-full"
|
secondaryStack.length === 0 ? 'bg-surface' : ''
|
||||||
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
|
)}
|
||||||
>
|
>
|
||||||
{item.element}
|
{secondaryStack.map((item, index) => (
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<TooManyRelaysAlertDialog />
|
||||||
<TooManyRelaysAlertDialog />
|
<CreateWalletGuideToast />
|
||||||
<CreateWalletGuideToast />
|
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
|
||||||
<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>
|
</NotificationProvider>
|
||||||
</CurrentRelaysProvider>
|
</CurrentRelaysProvider>
|
||||||
</SecondaryPageContext.Provider>
|
</SecondaryPageContext.Provider>
|
||||||
|
|||||||
257
src/application/handlers/ContentEventHandlers.ts
Normal 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)
|
||||||
|
}
|
||||||
215
src/application/handlers/FeedEventHandlers.ts
Normal 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)
|
||||||
|
}
|
||||||
220
src/application/handlers/RelayEventHandlers.ts
Normal 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)
|
||||||
|
}
|
||||||
205
src/application/handlers/SocialEventHandlers.ts
Normal 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)
|
||||||
|
}
|
||||||
121
src/application/handlers/index.ts
Normal 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')
|
||||||
|
}
|
||||||
@@ -10,3 +10,13 @@ export type { RelaySelectorOptions } from './RelaySelector'
|
|||||||
|
|
||||||
export { PublishingService, publishingService } from './PublishingService'
|
export { PublishingService, publishingService } from './PublishingService'
|
||||||
export type { DraftEvent, PublishNoteOptions } from './PublishingService'
|
export type { DraftEvent, PublishNoteOptions } from './PublishingService'
|
||||||
|
|
||||||
|
// Event Handlers
|
||||||
|
export {
|
||||||
|
initializeEventHandlers,
|
||||||
|
cleanupEventHandlers,
|
||||||
|
registerSocialEventHandlers,
|
||||||
|
unregisterSocialEventHandlers,
|
||||||
|
registerContentEventHandlers,
|
||||||
|
unregisterContentEventHandlers
|
||||||
|
} from './handlers'
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { isSameAccount } from '@/lib/account'
|
import { isSameAccount } from '@/lib/account'
|
||||||
import { formatPubkey } from '@/lib/pubkey'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { TAccountPointer } from '@/types'
|
import { TAccountPointer } from '@/types'
|
||||||
@@ -43,7 +43,7 @@ export default function AccountList({
|
|||||||
<div className="flex-1 w-0">
|
<div className="flex-1 w-0">
|
||||||
<SimpleUsername userId={act.pubkey} className="font-semibold truncate" />
|
<SimpleUsername userId={act.pubkey} className="font-semibold truncate" />
|
||||||
<div className="text-sm rounded-full bg-muted px-2 w-fit">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
src/components/ActionModeOverlay/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { userIdToPubkey } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import { useFollowList } from '@/providers/FollowListProvider'
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
import { UserRoundCheck } from 'lucide-react'
|
import { UserRoundCheck } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
@@ -10,7 +10,7 @@ export default function FollowingBadge({ pubkey, userId }: { pubkey?: string; us
|
|||||||
const isFollowing = useMemo(() => {
|
const isFollowing = useMemo(() => {
|
||||||
if (pubkey) return followingSet.has(pubkey)
|
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])
|
}, [followingSet, pubkey, userId])
|
||||||
|
|
||||||
if (!isFollowing) return null
|
if (!isFollowing) return null
|
||||||
|
|||||||
175
src/components/Help/index.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
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>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<KeyBinding keys={['Arrow Up', 'Arrow Down']} description={t('Move between items in a list')} />
|
||||||
|
<KeyBinding keys={['Arrow Left', 'Arrow Right']} description={t('Switch between columns (sidebar, feed, detail)')} />
|
||||||
|
<KeyBinding keys={['Enter']} description={t('Open or activate the selected item')} />
|
||||||
|
<KeyBinding keys={['Escape']} description={t('Close current view or go to sidebar')} />
|
||||||
|
<KeyBinding keys={['Backspace']} description={t('Go back to previous view')} />
|
||||||
|
<KeyBinding keys={['Page Up', 'Page Down']} description={t('Jump to top or bottom of list')} />
|
||||||
|
<KeyBinding keys={['Tab']} description={t('Cycle through note actions (reply, repost, quote, react, zap)')} />
|
||||||
|
</div>
|
||||||
|
</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, description }: { keys: string[]; description: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{keys.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,25 +1,39 @@
|
|||||||
import UserAvatar from '@/components/UserAvatar'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { formatTimestamp } from '@/lib/timestamp'
|
import { formatTimestamp } from '@/lib/timestamp'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { TConversation, TProfile } from '@/types'
|
import { TConversation, TProfile } from '@/types'
|
||||||
import { Lock, Users } from 'lucide-react'
|
import { Lock, Users, X } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface ConversationItemProps {
|
interface ConversationItemProps {
|
||||||
conversation: TConversation
|
conversation: TConversation
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isFollowing: boolean
|
isFollowing: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
onClose?: () => void
|
||||||
|
navIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConversationItem({
|
export default function ConversationItem({
|
||||||
conversation,
|
conversation,
|
||||||
isActive,
|
isActive,
|
||||||
isFollowing,
|
isFollowing,
|
||||||
onClick
|
onClick,
|
||||||
|
onClose,
|
||||||
|
navIndex
|
||||||
}: ConversationItemProps) {
|
}: ConversationItemProps) {
|
||||||
const [profile, setProfile] = useState<TProfile | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchProfileData = async () => {
|
const fetchProfileData = async () => {
|
||||||
@@ -39,13 +53,16 @@ export default function ConversationItem({
|
|||||||
const formattedTime = formatTimestamp(conversation.lastMessageAt)
|
const formattedTime = formatTimestamp(conversation.lastMessageAt)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div ref={navRef} className="scroll-mt-[6.5rem]">
|
||||||
onClick={onClick}
|
<button
|
||||||
className={cn(
|
ref={buttonRef}
|
||||||
'w-full flex items-start gap-3 p-3 hover:bg-accent/50 transition-colors text-left',
|
onClick={onClick}
|
||||||
isActive && 'bg-accent'
|
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" />
|
<UserAvatar userId={conversation.partnerPubkey} className="size-10 flex-shrink-0" />
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -58,7 +75,21 @@ export default function ConversationItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
@@ -76,6 +107,7 @@ export default function ConversationItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { toDMConversation } from '@/lib/link'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useDM } from '@/providers/DMProvider'
|
import { useDM } from '@/providers/DMProvider'
|
||||||
import { useFollowList } from '@/providers/FollowListProvider'
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
@@ -17,6 +19,7 @@ import ConversationItem from './ConversationItem'
|
|||||||
|
|
||||||
export default function ConversationList() {
|
export default function ConversationList() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { push, pop } = useSecondaryPage()
|
||||||
const {
|
const {
|
||||||
conversations,
|
conversations,
|
||||||
currentConversation,
|
currentConversation,
|
||||||
@@ -122,13 +125,24 @@ export default function ConversationList() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{sortedConversations.map((conversation) => (
|
{sortedConversations.map((conversation, index) => (
|
||||||
<ConversationItem
|
<ConversationItem
|
||||||
key={conversation.partnerPubkey}
|
key={conversation.partnerPubkey}
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
isActive={currentConversation === conversation.partnerPubkey}
|
isActive={currentConversation === conversation.partnerPubkey}
|
||||||
isFollowing={followingSet.has(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 */}
|
{/* Sentinel element for infinite scroll */}
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
import { useDM } from '@/providers/DMProvider'
|
import { useDM } from '@/providers/DMProvider'
|
||||||
import { Loader2, RefreshCw } from 'lucide-react'
|
import { Loader2, RefreshCw } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ConversationList from './ConversationList'
|
import ConversationList from './ConversationList'
|
||||||
import MessageView from './MessageView'
|
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../ui/button'
|
||||||
|
|
||||||
export default function InboxContent() {
|
export default function InboxContent() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isLoading, error, refreshConversations, currentConversation, selectConversation } =
|
const { isLoading, error, refreshConversations } = useDM()
|
||||||
useDM()
|
|
||||||
const [isMobileView, setIsMobileView] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (isLoading) {
|
||||||
const checkMobile = () => {
|
|
||||||
setIsMobileView(window.innerWidth < 768)
|
|
||||||
}
|
|
||||||
checkMobile()
|
|
||||||
window.addEventListener('resize', checkMobile)
|
|
||||||
return () => window.removeEventListener('resize', checkMobile)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (isLoading && !currentConversation) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
<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
|
// Conversations list - clicking opens in secondary panel (or overlay on mobile)
|
||||||
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
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)]">
|
<div className="h-[calc(100vh-8rem)]">
|
||||||
<div className="w-80 border-r flex-shrink-0 overflow-hidden">
|
<ConversationList />
|
||||||
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useDM } from '@/providers/DMProvider'
|
import { useDM } from '@/providers/DMProvider'
|
||||||
import { AlertCircle, Loader2, Send } from 'lucide-react'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useRef, useState } from 'react'
|
import { AlertCircle, ChevronDown, ChevronUp, Loader2, Send } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../ui/button'
|
||||||
import { Textarea } from '../ui/textarea'
|
import { Textarea } from '../ui/textarea'
|
||||||
@@ -8,18 +10,47 @@ import { Textarea } from '../ui/textarea'
|
|||||||
export default function MessageComposer() {
|
export default function MessageComposer() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { sendMessage, currentConversation } = useDM()
|
const { sendMessage, currentConversation } = useDM()
|
||||||
|
const { relayList } = useNostr()
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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)
|
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 () => {
|
const handleSend = async () => {
|
||||||
if (!message.trim() || !currentConversation || isSending) return
|
if (!message.trim() || !currentConversation || isSending) return
|
||||||
|
|
||||||
setIsSending(true)
|
setIsSending(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await sendMessage(message.trim())
|
const relaysToUse = Array.from(selectedRelays)
|
||||||
|
await sendMessage(message.trim(), relaysToUse.length > 0 ? relaysToUse : undefined)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
// Return focus to input after sending
|
// Return focus to input after sending
|
||||||
textareaRef.current?.focus()
|
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 (
|
return (
|
||||||
<div className="p-3 space-y-2">
|
<div className="p-3 space-y-2">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -46,6 +82,41 @@ export default function MessageComposer() {
|
|||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-end gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
|||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import indexedDb from '@/services/indexed-db.service'
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
import { TDirectMessage, TProfile } from '@/types'
|
import { TDirectMessage, TProfile } from '@/types'
|
||||||
import { ArrowLeft, ChevronDown, ChevronUp, 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 { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../ui/button'
|
||||||
@@ -232,11 +232,6 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
) : (
|
) : (
|
||||||
// Normal header
|
// Normal header
|
||||||
<>
|
<>
|
||||||
{onBack && (
|
|
||||||
<Button variant="ghost" size="icon" onClick={onBack} className="size-8">
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<UserAvatar userId={currentConversation} className="size-8" />
|
<UserAvatar userId={currentConversation} className="size-8" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -291,6 +286,17 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{onBack && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
title={t('Close conversation')}
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,10 +57,11 @@ export default function NewNotesButton({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<span className="text-xs opacity-70">⇧↵</span>
|
||||||
|
<ArrowUp />
|
||||||
<div className="text-md font-medium">
|
<div className="text-md font-medium">
|
||||||
{t('Show n new notes', { n: newEvents.length > 99 ? '99+' : newEvents.length })}
|
{t('Show n new notes', { n: newEvents.length > 99 ? '99+' : newEvents.length })}
|
||||||
</div>
|
</div>
|
||||||
<ArrowUp />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { createFakeEvent } from '@/lib/event'
|
import { createFakeEvent } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { isValidPubkey } from '@/lib/pubkey'
|
|
||||||
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
|
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
@@ -95,7 +95,7 @@ function HighlightSource({ event }: { event: Event }) {
|
|||||||
}
|
}
|
||||||
if (sourceTag && sourceTag[0] === 'a') {
|
if (sourceTag && sourceTag[0] === 'a') {
|
||||||
const [, pubkey] = sourceTag[1].split(':')
|
const [, pubkey] = sourceTag[1].split(':')
|
||||||
if (isValidPubkey(pubkey)) {
|
if (Pubkey.isValidHex(pubkey)) {
|
||||||
return pubkey
|
return pubkey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import Collapsible from '../Collapsible'
|
import Collapsible from '../Collapsible'
|
||||||
import Note from '../Note'
|
import Note from '../Note'
|
||||||
@@ -15,7 +17,9 @@ export default function MainNoteCard({
|
|||||||
reposters,
|
reposters,
|
||||||
embedded,
|
embedded,
|
||||||
originalNoteId,
|
originalNoteId,
|
||||||
pinned = false
|
pinned = false,
|
||||||
|
navColumn,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
@@ -23,12 +27,18 @@ export default function MainNoteCard({
|
|||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
originalNoteId?: string
|
originalNoteId?: string
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
|
const { ref, isSelected } = useKeyboardNavigable(navColumn ?? 1, navIndex ?? 0, {
|
||||||
|
meta: { type: 'note', event }
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
ref={ref}
|
||||||
|
className={cn(className, 'scroll-mt-[6.5rem]', isSelected && 'ring-2 ring-primary ring-inset')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
push(toNote(originalNoteId ?? event))
|
push(toNote(originalNoteId ?? event))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isMentioningMutedUsers } from '@/lib/event'
|
import { isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||||
@@ -12,13 +13,17 @@ export default function RepostNoteCard({
|
|||||||
className,
|
className,
|
||||||
filterMutedNotes = true,
|
filterMutedNotes = true,
|
||||||
pinned = false,
|
pinned = false,
|
||||||
reposters
|
reposters,
|
||||||
|
navColumn,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
filterMutedNotes?: boolean
|
filterMutedNotes?: boolean
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
reposters?: string[]
|
reposters?: string[]
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
@@ -92,6 +97,8 @@ export default function RepostNoteCard({
|
|||||||
reposters={reposters?.includes(event.pubkey) ? reposters : [event.pubkey]}
|
reposters={reposters?.includes(event.pubkey) ? reposters : [event.pubkey]}
|
||||||
event={targetEvent}
|
event={targetEvent}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
|
navColumn={navColumn}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NSFW_DISPLAY_POLICY } from '@/constants'
|
|||||||
import { isMentioningMutedUsers, isNsfwEvent } from '@/lib/event'
|
import { isMentioningMutedUsers, isNsfwEvent } from '@/lib/event'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
@@ -14,13 +15,17 @@ export default function NoteCard({
|
|||||||
className,
|
className,
|
||||||
filterMutedNotes = true,
|
filterMutedNotes = true,
|
||||||
pinned = false,
|
pinned = false,
|
||||||
reposters
|
reposters,
|
||||||
|
navColumn,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
filterMutedNotes?: boolean
|
filterMutedNotes?: boolean
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
reposters?: string[]
|
reposters?: string[]
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers, nsfwDisplayPolicy } = useContentPolicy()
|
const { hideContentMentioningMutedUsers, nsfwDisplayPolicy } = useContentPolicy()
|
||||||
@@ -46,10 +51,21 @@ export default function NoteCard({
|
|||||||
filterMutedNotes={filterMutedNotes}
|
filterMutedNotes={filterMutedNotes}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
reposters={reposters}
|
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 }) {
|
export function NoteCardLoadingSkeleton({ className }: { className?: string }) {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import RepostList from '../RepostList'
|
|||||||
import ZapList from '../ZapList'
|
import ZapList from '../ZapList'
|
||||||
import { Tabs, TTabValue } from './Tabs'
|
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')
|
const [type, setType] = useState<TTabValue>('replies')
|
||||||
let list
|
let list
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'replies':
|
case 'replies':
|
||||||
list = <ReplyNoteList stuff={event} />
|
list = <ReplyNoteList stuff={event} navIndexOffset={navIndexOffset} />
|
||||||
break
|
break
|
||||||
case 'quotes':
|
case 'quotes':
|
||||||
list = <QuoteList stuff={event} />
|
list = <QuoteList stuff={event} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { tagNameEquals } from '@/lib/tag'
|
|||||||
import { isTouchDevice } from '@/lib/utils'
|
import { isTouchDevice } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||||
|
import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
@@ -53,6 +54,7 @@ const NoteList = forwardRef<
|
|||||||
pinnedEventIds?: string[]
|
pinnedEventIds?: string[]
|
||||||
filterFn?: (event: Event) => boolean
|
filterFn?: (event: Event) => boolean
|
||||||
showNewNotesDirectly?: boolean
|
showNewNotesDirectly?: boolean
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -67,7 +69,8 @@ const NoteList = forwardRef<
|
|||||||
showRelayCloseReason = false,
|
showRelayCloseReason = false,
|
||||||
pinnedEventIds,
|
pinnedEventIds,
|
||||||
filterFn,
|
filterFn,
|
||||||
showNewNotesDirectly = false
|
showNewNotesDirectly = false,
|
||||||
|
navColumn = 1
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -77,6 +80,7 @@ const NoteList = forwardRef<
|
|||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
|
const { offsetSelection } = useKeyboardNavigation()
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
const [initialLoading, setInitialLoading] = useState(false)
|
const [initialLoading, setInitialLoading] = useState(false)
|
||||||
@@ -366,24 +370,41 @@ const NoteList = forwardRef<
|
|||||||
initialLoading
|
initialLoading
|
||||||
})
|
})
|
||||||
|
|
||||||
const showNewEvents = () => {
|
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])
|
setEvents((oldEvents) => [...newEvents, ...oldEvents])
|
||||||
setNewEvents([])
|
setNewEvents([])
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToTop('smooth')
|
scrollToTop('smooth')
|
||||||
}, 0)
|
}, 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 = (
|
const list = (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
||||||
{visibleItems.map(({ key, event, reposters }) => (
|
{visibleItems.map(({ key, event, reposters }, index) => (
|
||||||
<NoteCard
|
<NoteCard
|
||||||
key={key}
|
key={key}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
event={event}
|
event={event}
|
||||||
filterMutedNotes={filterMutedNotes}
|
filterMutedNotes={filterMutedNotes}
|
||||||
reposters={reposters}
|
reposters={reposters}
|
||||||
|
navColumn={navColumn}
|
||||||
|
navIndex={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { getNoteBech32Id, isProtectedEvent } from '@/lib/event'
|
import { getNoteBech32Id, isProtectedEvent } from '@/lib/event'
|
||||||
import { toNjump } from '@/lib/link'
|
import { toNjump } from '@/lib/link'
|
||||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
|
||||||
import { simplifyUrl } from '@/lib/url'
|
import { simplifyUrl } from '@/lib/url'
|
||||||
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
|
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||||
@@ -174,7 +174,7 @@ export function useMenuActions({
|
|||||||
icon: Copy,
|
icon: Copy,
|
||||||
label: t('Copy user ID'),
|
label: t('Copy user ID'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')
|
navigator.clipboard.writeText(Pubkey.tryFromString(event.pubkey)?.npub ?? '')
|
||||||
closeDrawer()
|
closeDrawer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function HighlightNotification({
|
export function HighlightNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ export function HighlightNotification({
|
|||||||
targetEvent={notification}
|
targetEvent={notification}
|
||||||
description={t('highlighted your note')}
|
description={t('highlighted your note')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function MentionNotification({
|
export function MentionNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
@@ -68,6 +70,7 @@ export function MentionNotification({
|
|||||||
}
|
}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
showStats
|
showStats
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import UserAvatar from '@/components/UserAvatar'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
import Username from '@/components/Username'
|
import Username from '@/components/Username'
|
||||||
import { NOTIFICATION_LIST_STYLE } from '@/constants'
|
import { NOTIFICATION_LIST_STYLE } from '@/constants'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { toNote, toProfile } from '@/lib/link'
|
import { toNote, toProfile } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
@@ -24,7 +25,8 @@ export default function Notification({
|
|||||||
middle = null,
|
middle = null,
|
||||||
targetEvent,
|
targetEvent,
|
||||||
isNew = false,
|
isNew = false,
|
||||||
showStats = false
|
showStats = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
notificationId: string
|
notificationId: string
|
||||||
@@ -35,6 +37,7 @@ export default function Notification({
|
|||||||
targetEvent?: NostrEvent
|
targetEvent?: NostrEvent
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
showStats?: boolean
|
showStats?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
@@ -46,6 +49,10 @@ export default function Notification({
|
|||||||
[isNew, isNotificationRead, notificationId]
|
[isNew, isNotificationRead, notificationId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { ref: navRef, isSelected } = useKeyboardNavigable(1, navIndex ?? 0, {
|
||||||
|
meta: { type: 'note' }
|
||||||
|
})
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
markNotificationAsRead(notificationId)
|
markNotificationAsRead(notificationId)
|
||||||
if (targetEvent) {
|
if (targetEvent) {
|
||||||
@@ -58,7 +65,11 @@ export default function Notification({
|
|||||||
if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) {
|
if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2 items-center flex-1 w-0">
|
<div className="flex gap-2 items-center flex-1 w-0">
|
||||||
@@ -84,7 +95,11 @@ export default function Notification({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2 items-center mt-1.5">
|
<div className="flex gap-2 items-center mt-1.5">
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
export function PollResponseNotification({
|
export function PollResponseNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const eventId = useMemo(() => {
|
const eventId = useMemo(() => {
|
||||||
@@ -33,6 +35,7 @@ export function PollResponseNotification({
|
|||||||
targetEvent={pollEvent}
|
targetEvent={pollEvent}
|
||||||
description={t('voted in your poll')}
|
description={t('voted in your poll')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function ReactionNotification({
|
export function ReactionNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
@@ -66,6 +68,7 @@ export function ReactionNotification({
|
|||||||
targetEvent={event}
|
targetEvent={event}
|
||||||
description={t('reacted to your note')}
|
description={t('reacted to your note')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function RepostNotification({
|
export function RepostNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const event = useMemo(() => {
|
const event = useMemo(() => {
|
||||||
@@ -35,6 +37,7 @@ export function RepostNotification({
|
|||||||
targetEvent={event}
|
targetEvent={event}
|
||||||
description={t('reposted your note')}
|
description={t('reposted your note')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function ZapNotification({
|
export function ZapNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { senderPubkey, eventId, amount, comment } = useMemo(
|
const { senderPubkey, eventId, amount, comment } = useMemo(
|
||||||
@@ -37,6 +39,7 @@ export function ZapNotification({
|
|||||||
}
|
}
|
||||||
description={event ? t('zapped your note') : t('zapped you')}
|
description={event ? t('zapped your note') : t('zapped you')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import { ZapNotification } from './ZapNotification'
|
|||||||
|
|
||||||
export function NotificationItem({
|
export function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
@@ -42,7 +44,7 @@ export function NotificationItem({
|
|||||||
if (!canShow) return null
|
if (!canShow) return null
|
||||||
|
|
||||||
if (notification.kind === kinds.Reaction) {
|
if (notification.kind === kinds.Reaction) {
|
||||||
return <ReactionNotification notification={notification} isNew={isNew} />
|
return <ReactionNotification notification={notification} isNew={isNew} navIndex={navIndex} />
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
notification.kind === kinds.ShortTextNote ||
|
notification.kind === kinds.ShortTextNote ||
|
||||||
@@ -50,19 +52,19 @@ export function NotificationItem({
|
|||||||
notification.kind === ExtendedKind.VOICE_COMMENT ||
|
notification.kind === ExtendedKind.VOICE_COMMENT ||
|
||||||
notification.kind === ExtendedKind.POLL
|
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) {
|
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) {
|
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) {
|
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) {
|
if (notification.kind === kinds.Highlights) {
|
||||||
return <HighlightNotification notification={notification} isNew={isNew} />
|
return <HighlightNotification notification={notification} isNew={isNew} navIndex={navIndex} />
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,11 +254,12 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
|
|
||||||
const list = (
|
const list = (
|
||||||
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}>
|
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}>
|
||||||
{visibleNotifications.map((notification) => (
|
{visibleNotifications.map((notification, index) => (
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
isNew={notification.created_at > lastReadTime}
|
isNew={notification.created_at > lastReadTime}
|
||||||
|
navIndex={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
|
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
|
||||||
import { toRelay } from '@/lib/link'
|
import { toRelay } from '@/lib/link'
|
||||||
import { userIdToPubkey } from '@/lib/pubkey'
|
|
||||||
import { TMailboxRelay } from '@/types'
|
import { TMailboxRelay } from '@/types'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -10,7 +10,7 @@ import RelaySimpleInfo from '../RelaySimpleInfo'
|
|||||||
|
|
||||||
export default function OthersRelayList({ userId }: { userId: string }) {
|
export default function OthersRelayList({ userId }: { userId: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
|
const pubkey = useMemo(() => Pubkey.tryFromString(userId)?.hex ?? userId, [userId])
|
||||||
const { relayList, isFetching } = useFetchRelayList(pubkey)
|
const { relayList, isFetching } = useFetchRelayList(pubkey)
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import FollowingBadge from '@/components/FollowingBadge'
|
import FollowingBadge from '@/components/FollowingBadge'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
|
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||||
@@ -24,7 +24,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
|
|||||||
const item = props.items[index]
|
const item = props.items[index]
|
||||||
|
|
||||||
if (item) {
|
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" />
|
<SimpleUsername userId={item} className="font-semibold truncate" />
|
||||||
<FollowingBadge userId={item} />
|
<FollowingBadge userId={item} />
|
||||||
</div>
|
</div>
|
||||||
<Nip05 pubkey={userIdToPubkey(item)} />
|
<Nip05 pubkey={Pubkey.tryFromString(item)?.hex ?? item} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import TextWithEmojis from '@/components/TextWithEmojis'
|
import TextWithEmojis from '@/components/TextWithEmojis'
|
||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { useFetchProfile } from '@/hooks'
|
import { useFetchProfile } from '@/hooks'
|
||||||
import { formatUserId } from '@/lib/pubkey'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
|
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export default function MentionNode(props: NodeViewRendererProps & { selected: b
|
|||||||
{profile ? (
|
{profile ? (
|
||||||
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
|
<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>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatNpub } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import TTMention from '@tiptap/extension-mention'
|
import TTMention from '@tiptap/extension-mention'
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
import MentionNode from './MentionNode'
|
import MentionNode from './MentionNode'
|
||||||
@@ -34,7 +34,7 @@ const Mention = TTMention.extend({
|
|||||||
type: 'mention',
|
type: 'mention',
|
||||||
attrs: {
|
attrs: {
|
||||||
id: npub,
|
id: npub,
|
||||||
label: formatNpub(npub)
|
label: Pubkey.tryFromString(npub)?.formatNpub(12) ?? npub.slice(0, 12)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { useFetchProfile } from '@/hooks'
|
import { useFetchProfile } from '@/hooks'
|
||||||
import { userIdToPubkey } from '@/lib/pubkey'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import FollowButton from '../FollowButton'
|
import FollowButton from '../FollowButton'
|
||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
@@ -9,7 +9,7 @@ import TrustScoreBadge from '../TrustScoreBadge'
|
|||||||
import { SimpleUserAvatar } from '../UserAvatar'
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
|
|
||||||
export default function ProfileCard({ userId }: { userId: string }) {
|
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 { profile } = useFetchProfile(userId)
|
||||||
const { username, about, emojis } = profile || {}
|
const { username, about, emojis } = profile || {}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
@@ -50,7 +50,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDrawerOpen(false)
|
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"
|
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -109,7 +109,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}>
|
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(Pubkey.tryFromString(pubkey)?.npub ?? '')}>
|
||||||
<Copy />
|
<Copy />
|
||||||
{t('Copy user ID')}
|
{t('Copy user ID')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { formatNpub } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import { Check, Copy } from 'lucide-react'
|
import { Check, Copy } from 'lucide-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
|
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 [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const copyNpub = () => {
|
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"
|
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full clickable"
|
||||||
onClick={() => copyNpub()}
|
onClick={() => copyNpub()}
|
||||||
>
|
>
|
||||||
<div>{formatNpub(npub, 24)}</div>
|
<div>{pk?.formatNpub(24) ?? npub}</div>
|
||||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { useThread } from '@/hooks/useThread'
|
import { useThread } from '@/hooks/useThread'
|
||||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
@@ -29,13 +31,17 @@ export default function ReplyNote({
|
|||||||
parentEventId,
|
parentEventId,
|
||||||
onClickParent = () => {},
|
onClickParent = () => {},
|
||||||
highlight = false,
|
highlight = false,
|
||||||
className = ''
|
className = '',
|
||||||
|
navColumn,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
parentEventId?: string
|
parentEventId?: string
|
||||||
onClickParent?: () => void
|
onClickParent?: () => void
|
||||||
highlight?: boolean
|
highlight?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
@@ -46,6 +52,13 @@ export default function ReplyNote({
|
|||||||
const eventKey = useMemo(() => getEventKey(event), [event])
|
const eventKey = useMemo(() => getEventKey(event), [event])
|
||||||
const replies = useThread(eventKey)
|
const replies = useThread(eventKey)
|
||||||
const [showMuted, setShowMuted] = useState(false)
|
const [showMuted, setShowMuted] = useState(false)
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
const { ref: navRef, isSelected } = useKeyboardNavigable(
|
||||||
|
navColumn ?? 2,
|
||||||
|
navIndex ?? 0,
|
||||||
|
{ meta: { type: 'note', event } }
|
||||||
|
)
|
||||||
const show = useMemo(() => {
|
const show = useMemo(() => {
|
||||||
if (showMuted) {
|
if (showMuted) {
|
||||||
return true
|
return true
|
||||||
@@ -79,9 +92,11 @@ export default function ReplyNote({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={navRef}
|
||||||
className={cn(
|
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' : '',
|
highlight ? 'bg-primary/40' : '',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-inset',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={() => push(toNote(event))}
|
onClick={() => push(toNote(event))}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { useAllDescendantThreads } from '@/hooks/useThread'
|
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||||
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
|
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
@@ -13,8 +14,15 @@ import { useCallback, useMemo, useRef, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReplyNote from '../ReplyNote'
|
import ReplyNote from '../ReplyNote'
|
||||||
|
|
||||||
export default function SubReplies({ parentKey }: { parentKey: string }) {
|
export default function SubReplies({
|
||||||
const { t } = useTranslation()
|
parentKey,
|
||||||
|
revealerNavIndex,
|
||||||
|
subReplyNavIndexStart
|
||||||
|
}: {
|
||||||
|
parentKey: string
|
||||||
|
revealerNavIndex?: number
|
||||||
|
subReplyNavIndexStart?: number
|
||||||
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const allThreads = useAllDescendantThreads(parentKey)
|
const allThreads = useAllDescendantThreads(parentKey)
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
@@ -86,37 +94,12 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{replies.length > 1 && (
|
{replies.length > 1 && (
|
||||||
<button
|
<Revealer
|
||||||
onClick={(e) => {
|
isExpanded={isExpanded}
|
||||||
e.stopPropagation()
|
onToggle={() => setIsExpanded((prev) => !prev)}
|
||||||
setIsExpanded(!isExpanded)
|
replyCount={replies.length}
|
||||||
}}
|
navIndex={revealerNavIndex}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
{(isExpanded || replies.length === 1) && (
|
{(isExpanded || replies.length === 1) && (
|
||||||
<div>
|
<div>
|
||||||
@@ -139,6 +122,8 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
|||||||
<ReplyNote
|
<ReplyNote
|
||||||
className="flex-1 w-0 pl-10"
|
className="flex-1 w-0 pl-10"
|
||||||
event={reply}
|
event={reply}
|
||||||
|
navColumn={2}
|
||||||
|
navIndex={subReplyNavIndexStart !== undefined ? subReplyNavIndexStart + index : undefined}
|
||||||
parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
|
parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
|
||||||
onClickParent={() => {
|
onClickParent={() => {
|
||||||
if (!_parentKey) return
|
if (!_parentKey) return
|
||||||
@@ -154,3 +139,60 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import SubReplies from './SubReplies'
|
|||||||
const LIMIT = 100
|
const LIMIT = 100
|
||||||
const SHOW_COUNT = 10
|
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 { t } = useTranslation()
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
@@ -90,8 +90,8 @@ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
|
|||||||
<div className="min-h-[80vh]">
|
<div className="min-h-[80vh]">
|
||||||
{(loading || initialLoading) && <LoadingBar />}
|
{(loading || initialLoading) && <LoadingBar />}
|
||||||
<div>
|
<div>
|
||||||
{visibleItems.map((reply) => (
|
{visibleItems.map((reply, index) => (
|
||||||
<Item key={reply.id} reply={reply} />
|
<Item key={reply.id} reply={reply} navIndex={navIndexOffset + index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div ref={bottomRef} />
|
<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 key = useMemo(() => getEventKey(reply), [reply])
|
||||||
|
const baseNavIndex = navIndex * NAV_INDEX_MULTIPLIER
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative border-b">
|
<div className="relative border-b">
|
||||||
<ReplyNote event={reply} />
|
<ReplyNote event={reply} navColumn={2} navIndex={baseNavIndex} />
|
||||||
<SubReplies parentKey={key} />
|
<SubReplies parentKey={key} revealerNavIndex={baseNavIndex + 1} subReplyNavIndexStart={baseNavIndex + 2} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ import {
|
|||||||
Wallet
|
Wallet
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { kinds } from 'nostr-tools'
|
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 { useTranslation } from 'react-i18next'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
|
|
||||||
type TEmojiTab = 'my-packs' | 'explore'
|
type TEmojiTab = 'my-packs' | 'explore'
|
||||||
|
|
||||||
@@ -100,6 +101,9 @@ const NOTIFICATION_STYLES = [
|
|||||||
{ key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
|
{ key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
// Accordion item values for keyboard navigation
|
||||||
|
const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system']
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { pubkey, nsec, ncryptsec } = useNostr()
|
const { pubkey, nsec, ncryptsec } = useNostr()
|
||||||
@@ -107,6 +111,78 @@ export default function Settings() {
|
|||||||
const [copiedNsec, setCopiedNsec] = useState(false)
|
const [copiedNsec, setCopiedNsec] = useState(false)
|
||||||
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
||||||
const [openSection, setOpenSection] = useState<string>('')
|
const [openSection, setOpenSection] = useState<string>('')
|
||||||
|
const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1)
|
||||||
|
const accordionRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
const { activeColumn, registerSettingsHandlers, unregisterSettingsHandlers } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
// Get the visible accordion items based on pubkey availability
|
||||||
|
const visibleAccordionItems = pubkey
|
||||||
|
? ACCORDION_ITEMS
|
||||||
|
: ACCORDION_ITEMS.filter((item) => !['wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
|
||||||
|
|
||||||
|
// Register keyboard handlers for settings page navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeColumn !== 1) {
|
||||||
|
setSelectedAccordionIndex(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
onUp: () => {
|
||||||
|
setSelectedAccordionIndex((prev) => {
|
||||||
|
const newIndex = prev <= 0 ? 0 : prev - 1
|
||||||
|
setTimeout(() => {
|
||||||
|
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}, 0)
|
||||||
|
return newIndex
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDown: () => {
|
||||||
|
setSelectedAccordionIndex((prev) => {
|
||||||
|
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
|
||||||
|
setTimeout(() => {
|
||||||
|
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}, 0)
|
||||||
|
return newIndex
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onEnter: () => {
|
||||||
|
if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
|
||||||
|
const value = visibleAccordionItems[selectedAccordionIndex]
|
||||||
|
setOpenSection((prev) => (prev === value ? '' : value))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEscape: () => {
|
||||||
|
if (openSection) {
|
||||||
|
setOpenSection('')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSettingsHandlers(handlers)
|
||||||
|
return () => unregisterSettingsHandlers()
|
||||||
|
}, [activeColumn, selectedAccordionIndex, openSection, visibleAccordionItems])
|
||||||
|
|
||||||
|
// 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
|
// General settings
|
||||||
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
||||||
@@ -183,13 +259,14 @@ export default function Settings() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{/* General */}
|
{/* General */}
|
||||||
<AccordionItem value="general">
|
<NavigableAccordionItem ref={setAccordionRef('general')} isSelected={isAccordionSelected('general')}>
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionItem value="general">
|
||||||
<div className="flex items-center gap-4">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<Settings2 className="size-4" />
|
<div className="flex items-center gap-4">
|
||||||
<span>{t('General')}</span>
|
<Settings2 className="size-4" />
|
||||||
</div>
|
<span>{t('General')}</span>
|
||||||
</AccordionTrigger>
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-4 space-y-4">
|
<AccordionContent className="px-4 space-y-4">
|
||||||
<SettingItem>
|
<SettingItem>
|
||||||
<Label htmlFor="languages" className="text-base font-normal">
|
<Label htmlFor="languages" className="text-base font-normal">
|
||||||
@@ -331,10 +408,12 @@ export default function Settings() {
|
|||||||
</SettingItem>
|
</SettingItem>
|
||||||
)}
|
)}
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<AccordionItem value="appearance">
|
<NavigableAccordionItem ref={setAccordionRef('appearance')} isSelected={isAccordionSelected('appearance')}>
|
||||||
|
<AccordionItem value="appearance">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Palette className="size-4" />
|
<Palette className="size-4" />
|
||||||
@@ -406,10 +485,12 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
|
|
||||||
{/* Relays */}
|
{/* Relays */}
|
||||||
<AccordionItem value="relays">
|
<NavigableAccordionItem ref={setAccordionRef('relays')} isSelected={isAccordionSelected('relays')}>
|
||||||
|
<AccordionItem value="relays">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Server className="size-4" />
|
<Server className="size-4" />
|
||||||
@@ -430,11 +511,13 @@ export default function Settings() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</RadixTabs>
|
</RadixTabs>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
|
|
||||||
{/* Wallet */}
|
{/* Wallet */}
|
||||||
{!!pubkey && (
|
{!!pubkey && (
|
||||||
<AccordionItem value="wallet">
|
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
|
||||||
|
<AccordionItem value="wallet">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Wallet className="size-4" />
|
<Wallet className="size-4" />
|
||||||
@@ -483,27 +566,31 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Post Settings */}
|
{/* Post Settings */}
|
||||||
{!!pubkey && (
|
{!!pubkey && (
|
||||||
<AccordionItem value="posts">
|
<NavigableAccordionItem ref={setAccordionRef('posts')} isSelected={isAccordionSelected('posts')}>
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionItem value="posts">
|
||||||
<div className="flex items-center gap-4">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<PencilLine className="size-4" />
|
<div className="flex items-center gap-4">
|
||||||
<span>{t('Post settings')}</span>
|
<PencilLine className="size-4" />
|
||||||
</div>
|
<span>{t('Post settings')}</span>
|
||||||
</AccordionTrigger>
|
</div>
|
||||||
<AccordionContent className="px-4">
|
</AccordionTrigger>
|
||||||
<MediaUploadServiceSetting />
|
<AccordionContent className="px-4">
|
||||||
</AccordionContent>
|
<MediaUploadServiceSetting />
|
||||||
</AccordionItem>
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Emoji Packs */}
|
{/* Emoji Packs */}
|
||||||
{!!pubkey && (
|
{!!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">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Smile className="size-4" />
|
<Smile className="size-4" />
|
||||||
@@ -529,45 +616,49 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Messaging */}
|
{/* Messaging */}
|
||||||
{!!pubkey && (
|
{!!pubkey && (
|
||||||
<AccordionItem value="messaging">
|
<NavigableAccordionItem ref={setAccordionRef('messaging')} isSelected={isAccordionSelected('messaging')}>
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionItem value="messaging">
|
||||||
<div className="flex items-center gap-4">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<MessageSquare className="size-4" />
|
<div className="flex items-center gap-4">
|
||||||
<span>{t('Messaging')}</span>
|
<MessageSquare className="size-4" />
|
||||||
</div>
|
<span>{t('Messaging')}</span>
|
||||||
</AccordionTrigger>
|
</div>
|
||||||
<AccordionContent className="px-4 space-y-4">
|
</AccordionTrigger>
|
||||||
<SettingItem>
|
<AccordionContent className="px-4 space-y-4">
|
||||||
<Label htmlFor="prefer-nip44" className="text-base font-normal">
|
<SettingItem>
|
||||||
<div>{t('Prefer NIP-44 encryption')}</div>
|
<Label htmlFor="prefer-nip44" className="text-base font-normal">
|
||||||
<div className="text-muted-foreground text-sm">
|
<div>{t('Prefer NIP-44 encryption')}</div>
|
||||||
{t('Use modern encryption for new conversations')}
|
<div className="text-muted-foreground text-sm">
|
||||||
</div>
|
{t('Use modern encryption for new conversations')}
|
||||||
</Label>
|
</div>
|
||||||
<Switch
|
</Label>
|
||||||
id="prefer-nip44"
|
<Switch
|
||||||
checked={preferNip44}
|
id="prefer-nip44"
|
||||||
onCheckedChange={(checked) => {
|
checked={preferNip44}
|
||||||
storage.setPreferNip44(checked)
|
onCheckedChange={(checked) => {
|
||||||
setPreferNip44(checked)
|
storage.setPreferNip44(checked)
|
||||||
dispatchSettingsChanged()
|
setPreferNip44(checked)
|
||||||
}}
|
dispatchSettingsChanged()
|
||||||
/>
|
}}
|
||||||
</SettingItem>
|
/>
|
||||||
</AccordionContent>
|
</SettingItem>
|
||||||
</AccordionItem>
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System */}
|
{/* System */}
|
||||||
<AccordionItem value="system">
|
<NavigableAccordionItem ref={setAccordionRef('system')} isSelected={isAccordionSelected('system')}>
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionItem value="system">
|
||||||
<div className="flex items-center gap-4">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<Cog className="size-4" />
|
<div className="flex items-center gap-4">
|
||||||
|
<Cog className="size-4" />
|
||||||
<span>{t('System')}</span>
|
<span>{t('System')}</span>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
@@ -599,7 +690,8 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
{/* Non-accordion items */}
|
{/* Non-accordion items */}
|
||||||
@@ -697,3 +789,25 @@ const OptionButton = ({
|
|||||||
</button>
|
</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'
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { Bookmark } from 'lucide-react'
|
import { Bookmark } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
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 { navigate, current, display } = usePrimaryPage()
|
||||||
const { checkLogin } = useNostr()
|
const { checkLogin } = useNostr()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
checkLogin(() => {
|
||||||
|
navigate('bookmark')
|
||||||
|
clearColumn(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Bookmarks"
|
title="Bookmarks"
|
||||||
onClick={() => checkLogin(() => navigate('bookmark'))}
|
onClick={handleClick}
|
||||||
active={display && current === 'bookmark'}
|
active={display && current === 'bookmark'}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<Bookmark />
|
<Bookmark />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
34
src/components/Sidebar/HelpButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { Home } from 'lucide-react'
|
import { Home } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
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 { navigate, current, display } = usePrimaryPage()
|
||||||
|
const { resetPrimarySelection, clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
navigate('home')
|
||||||
|
clearColumn(1)
|
||||||
|
resetPrimarySelection()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Home"
|
title="Home"
|
||||||
onClick={() => navigate('home')}
|
onClick={handleClick}
|
||||||
active={display && current === 'home'}
|
active={display && current === 'home'}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<Home />
|
<Home />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
import { useDM } from '@/providers/DMProvider'
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { MessageSquare } from 'lucide-react'
|
import { MessageSquare } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
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 { navigate, current, display } = usePrimaryPage()
|
||||||
const { hasNewMessages } = useDM()
|
const { hasNewMessages } = useDM()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
navigate('inbox')
|
||||||
|
clearColumn(1)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Inbox"
|
title="Inbox"
|
||||||
onClick={() => navigate('inbox')}
|
onClick={handleClick}
|
||||||
active={display && current === 'inbox'}
|
active={display && current === 'inbox'}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<MessageSquare />
|
<MessageSquare />
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useNotification } from '@/providers/NotificationProvider'
|
import { useNotification } from '@/providers/NotificationProvider'
|
||||||
import { Bell } from 'lucide-react'
|
import { Bell } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
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 { checkLogin } = useNostr()
|
||||||
const { navigate, current, display } = usePrimaryPage()
|
const { navigate, current, display } = usePrimaryPage()
|
||||||
const { hasNewNotification } = useNotification()
|
const { hasNewNotification } = useNotification()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
checkLogin(() => {
|
||||||
|
navigate('notifications')
|
||||||
|
clearColumn(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
onClick={() => checkLogin(() => navigate('notifications'))}
|
onClick={handleClick}
|
||||||
active={display && current === 'notifications'}
|
active={display && current === 'notifications'}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Bell />
|
<Bell />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PencilLine } from 'lucide-react'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import SidebarItem from './SidebarItem'
|
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 { checkLogin } = useNostr()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ export default function PostButton({ collapse }: { collapse: boolean }) {
|
|||||||
variant="default"
|
variant="default"
|
||||||
className={cn('bg-primary gap-2', !collapse && 'justify-center')}
|
className={cn('bg-primary gap-2', !collapse && 'justify-center')}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<PencilLine />
|
<PencilLine />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { UserRound } from 'lucide-react'
|
import { UserRound } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
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 { navigate, current, display } = usePrimaryPage()
|
||||||
const { checkLogin } = useNostr()
|
const { checkLogin } = useNostr()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
checkLogin(() => {
|
||||||
|
navigate('profile')
|
||||||
|
clearColumn(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Profile"
|
title="Profile"
|
||||||
onClick={() => checkLogin(() => navigate('profile'))}
|
onClick={handleClick}
|
||||||
active={display && current === 'profile'}
|
active={display && current === 'profile'}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<UserRound />
|
<UserRound />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
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 { navigate, current, display } = usePrimaryPage()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
navigate('search')
|
||||||
|
clearColumn(1)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Search"
|
title="Search"
|
||||||
onClick={() => navigate('search')}
|
onClick={handleClick}
|
||||||
active={current === 'search' && display}
|
active={current === 'search' && display}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<Search />
|
<Search />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
import { toSettings } from '@/lib/link'
|
import { toSettings } from '@/lib/link'
|
||||||
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
|
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||||
import { Settings } from 'lucide-react'
|
import { Settings } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
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 { current, navigate, display } = usePrimaryPage()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const { enableSingleColumnLayout } = useUserPreferences()
|
const { enableSingleColumnLayout } = useUserPreferences()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (enableSingleColumnLayout) {
|
||||||
|
navigate('settings')
|
||||||
|
clearColumn(1)
|
||||||
|
} else {
|
||||||
|
push(toSettings())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Settings"
|
title="Settings"
|
||||||
onClick={() => (enableSingleColumnLayout ? navigate('settings') : push(toSettings()))}
|
onClick={handleClick}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
active={display && current === 'settings'}
|
active={display && current === 'settings'}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<Settings />
|
<Settings />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
@@ -1,32 +1,52 @@
|
|||||||
import { Button, ButtonProps } from '@/components/ui/button'
|
import { Button, ButtonProps } from '@/components/ui/button'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { forwardRef } from 'react'
|
import { forwardRef, useCallback, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const SidebarItem = forwardRef<
|
const SidebarItem = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
ButtonProps & { title: string; collapse: boolean; description?: string; active?: boolean }
|
ButtonProps & {
|
||||||
>(({ children, title, description, className, active, collapse, ...props }, ref) => {
|
title: string
|
||||||
|
collapse: boolean
|
||||||
|
description?: string
|
||||||
|
active?: boolean
|
||||||
|
navIndex?: number
|
||||||
|
}
|
||||||
|
>(({ children, title, description, className, active, collapse, navIndex, onClick, ...props }, _ref) => {
|
||||||
const { t } = useTranslation()
|
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 (
|
return (
|
||||||
<Button
|
<div ref={navRef}>
|
||||||
className={cn(
|
<Button
|
||||||
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-4 text-lg font-semibold',
|
className={cn(
|
||||||
collapse
|
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-4 text-lg font-semibold',
|
||||||
? 'w-12 h-12 p-3 [&_svg]:size-full'
|
collapse
|
||||||
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
|
? 'w-12 h-12 p-3 [&_svg]:size-full'
|
||||||
active && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10',
|
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
|
||||||
className
|
active && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10',
|
||||||
)}
|
isSelected && 'ring-2 ring-primary ring-offset-2 ring-offset-background',
|
||||||
variant="ghost"
|
className
|
||||||
title={t(title)}
|
)}
|
||||||
ref={ref}
|
variant="ghost"
|
||||||
{...props}
|
title={t(title)}
|
||||||
>
|
ref={buttonRef}
|
||||||
{children}
|
onClick={onClick}
|
||||||
{!collapse && <div>{t(description ?? title)}</div>}
|
{...props}
|
||||||
</Button>
|
>
|
||||||
|
{children}
|
||||||
|
{!collapse && <div>{t(description ?? title)}</div>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
SidebarItem.displayName = 'SidebarItem'
|
SidebarItem.displayName = 'SidebarItem'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
|||||||
import { ChevronsLeft, ChevronsRight } from 'lucide-react'
|
import { ChevronsLeft, ChevronsRight } from 'lucide-react'
|
||||||
import AccountButton from './AccountButton'
|
import AccountButton from './AccountButton'
|
||||||
import BookmarkButton from './BookmarkButton'
|
import BookmarkButton from './BookmarkButton'
|
||||||
|
import HelpButton from './HelpButton'
|
||||||
import HomeButton from './HomeButton'
|
import HomeButton from './HomeButton'
|
||||||
import InboxButton from './InboxButton'
|
import InboxButton from './InboxButton'
|
||||||
import LayoutSwitcher from './LayoutSwitcher'
|
import LayoutSwitcher from './LayoutSwitcher'
|
||||||
@@ -55,16 +56,17 @@ export default function PrimaryPageSidebar() {
|
|||||||
<Logo />
|
<Logo />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<HomeButton collapse={isCollapsed} />
|
<HomeButton collapse={isCollapsed} navIndex={0} />
|
||||||
<NotificationsButton collapse={isCollapsed} />
|
<NotificationsButton collapse={isCollapsed} navIndex={1} />
|
||||||
<SearchButton collapse={isCollapsed} />
|
<SearchButton collapse={isCollapsed} navIndex={2} />
|
||||||
{pubkey && <InboxButton collapse={isCollapsed} />}
|
{pubkey && <InboxButton collapse={isCollapsed} navIndex={3} />}
|
||||||
<ProfileButton collapse={isCollapsed} />
|
<ProfileButton collapse={isCollapsed} navIndex={pubkey ? 4 : 3} />
|
||||||
{pubkey && <BookmarkButton collapse={isCollapsed} />}
|
{pubkey && <BookmarkButton collapse={isCollapsed} navIndex={5} />}
|
||||||
<SettingsButton collapse={isCollapsed} />
|
<SettingsButton collapse={isCollapsed} navIndex={pubkey ? 6 : 4} />
|
||||||
<PostButton collapse={isCollapsed} />
|
<PostButton collapse={isCollapsed} navIndex={pubkey ? 7 : 5} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<HelpButton collapse={isCollapsed} navIndex={pubkey ? 8 : 6} />
|
||||||
<LayoutSwitcher collapse={isCollapsed} />
|
<LayoutSwitcher collapse={isCollapsed} />
|
||||||
<AccountButton collapse={isCollapsed} />
|
<AccountButton collapse={isCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
|||||||
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground"
|
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground"
|
||||||
title={t('Like')}
|
title={t('Like')}
|
||||||
disabled={liking}
|
disabled={liking}
|
||||||
|
data-action="react"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseDown={handleLongPressStart}
|
onMouseDown={handleLongPressStart}
|
||||||
onMouseUp={handleLongPressEnd}
|
onMouseUp={handleLongPressEnd}
|
||||||
@@ -181,6 +182,7 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
|||||||
onMoreButtonClick={() => {
|
onMoreButtonClick={() => {
|
||||||
setIsPickerOpen(true)
|
setIsPickerOpen(true)
|
||||||
}}
|
}}
|
||||||
|
onClose={() => setIsEmojiReactionsOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
title={t('Reply')}
|
title={t('Reply')}
|
||||||
|
data-action="reply"
|
||||||
>
|
>
|
||||||
<MessageCircle />
|
<MessageCircle />
|
||||||
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
|||||||
)}
|
)}
|
||||||
disabled={!event}
|
disabled={!event}
|
||||||
title={t('Repost')}
|
title={t('Repost')}
|
||||||
|
data-action="repost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!event) return
|
if (!event) return
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
|||||||
setIsPostDialogOpen(true)
|
setIsPostDialogOpen(true)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
data-action="quote"
|
||||||
>
|
>
|
||||||
<PencilLine /> {t('Quote')}
|
<PencilLine /> {t('Quote')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
|||||||
)}
|
)}
|
||||||
title={t('Zap')}
|
title={t('Zap')}
|
||||||
disabled={disable || zapping}
|
disabled={disable || zapping}
|
||||||
|
data-action="zap"
|
||||||
onMouseDown={handleClickStart}
|
onMouseDown={handleClickStart}
|
||||||
onMouseUp={handleClickEnd}
|
onMouseUp={handleClickEnd}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function StuffStats({
|
|||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('select-none', className)}>
|
<div className={cn('select-none', className)} data-stuff-stats>
|
||||||
{displayTopZapsAndLikes && (
|
{displayTopZapsAndLikes && (
|
||||||
<>
|
<>
|
||||||
<TopZaps stuff={stuff} />
|
<TopZaps stuff={stuff} />
|
||||||
@@ -69,7 +69,7 @@ export default function StuffStats({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('select-none', className)}>
|
<div className={cn('select-none', className)} data-stuff-stats>
|
||||||
{displayTopZapsAndLikes && (
|
{displayTopZapsAndLikes && (
|
||||||
<>
|
<>
|
||||||
<TopZaps stuff={stuff} />
|
<TopZaps stuff={stuff} />
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { parseEmojiPickerUnified } from '@/lib/utils'
|
import { parseEmojiPickerUnified } from '@/lib/utils'
|
||||||
import { TEmoji } from '@/types'
|
import { TEmoji } from '@/types'
|
||||||
import { getSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
|
import { getSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
|
||||||
import { MoreHorizontal } from 'lucide-react'
|
import { MoreHorizontal } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import Emoji from '../Emoji'
|
import Emoji from '../Emoji'
|
||||||
|
|
||||||
const DEFAULT_SUGGESTED_EMOJIS = ['👍', '❤️', '😂', '🥲', '👀', '🫡', '🫂']
|
const DEFAULT_SUGGESTED_EMOJIS = ['👍', '❤️', '😂', '🥲', '👀', '🫡', '🫂']
|
||||||
|
|
||||||
export default function SuggestedEmojis({
|
export default function SuggestedEmojis({
|
||||||
onEmojiClick,
|
onEmojiClick,
|
||||||
onMoreButtonClick
|
onMoreButtonClick,
|
||||||
|
onClose
|
||||||
}: {
|
}: {
|
||||||
onEmojiClick: (emoji: string | TEmoji) => void
|
onEmojiClick: (emoji: string | TEmoji) => void
|
||||||
onMoreButtonClick: () => void
|
onMoreButtonClick: () => void
|
||||||
|
onClose?: () => void
|
||||||
}) {
|
}) {
|
||||||
const [suggestedEmojis, setSuggestedEmojis] =
|
const [suggestedEmojis, setSuggestedEmojis] =
|
||||||
useState<(string | TEmoji)[]>(DEFAULT_SUGGESTED_EMOJIS)
|
useState<(string | TEmoji)[]>(DEFAULT_SUGGESTED_EMOJIS)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Total items: 1 (plus) + suggestedEmojis.length + 1 (more button)
|
||||||
|
const totalItems = 1 + suggestedEmojis.length + 1
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -41,10 +49,72 @@ export default function SuggestedEmojis({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Focus container on mount for keyboard events
|
||||||
|
useEffect(() => {
|
||||||
|
containerRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelect = useCallback(() => {
|
||||||
|
if (selectedIndex === 0) {
|
||||||
|
// Plus button
|
||||||
|
onEmojiClick('+')
|
||||||
|
} else if (selectedIndex <= suggestedEmojis.length) {
|
||||||
|
// Emoji
|
||||||
|
onEmojiClick(suggestedEmojis[selectedIndex - 1])
|
||||||
|
} else {
|
||||||
|
// More button
|
||||||
|
onMoreButtonClick()
|
||||||
|
}
|
||||||
|
}, [selectedIndex, suggestedEmojis, onEmojiClick, onMoreButtonClick])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1))
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0))
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
// Jump to first item
|
||||||
|
setSelectedIndex(0)
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
// Jump to last item (more button)
|
||||||
|
setSelectedIndex(totalItems - 1)
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
handleSelect()
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
onClose?.()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[totalItems, handleSelect, onClose]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 p-1" onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex gap-1 p-1 outline-none"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl"
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl',
|
||||||
|
selectedIndex === 0 && 'ring-2 ring-primary'
|
||||||
|
)}
|
||||||
onClick={() => onEmojiClick('+')}
|
onClick={() => onEmojiClick('+')}
|
||||||
>
|
>
|
||||||
<Emoji emoji="+" />
|
<Emoji emoji="+" />
|
||||||
@@ -53,14 +123,20 @@ export default function SuggestedEmojis({
|
|||||||
typeof emoji === 'string' ? (
|
typeof emoji === 'string' ? (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl"
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl',
|
||||||
|
selectedIndex === index + 1 && 'ring-2 ring-primary'
|
||||||
|
)}
|
||||||
onClick={() => onEmojiClick(emoji)}
|
onClick={() => onEmojiClick(emoji)}
|
||||||
>
|
>
|
||||||
{emoji}
|
{emoji}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col items-center justify-center p-1 rounded-lg clickable"
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center p-1 rounded-lg clickable',
|
||||||
|
selectedIndex === index + 1 && 'ring-2 ring-primary'
|
||||||
|
)}
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => onEmojiClick(emoji)}
|
onClick={() => onEmojiClick(emoji)}
|
||||||
>
|
>
|
||||||
@@ -68,7 +144,14 @@ export default function SuggestedEmojis({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" className="w-8 h-8 text-muted-foreground" onClick={onMoreButtonClick}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 text-muted-foreground',
|
||||||
|
selectedIndex === totalItems - 1 && 'ring-2 ring-primary'
|
||||||
|
)}
|
||||||
|
onClick={onMoreButtonClick}
|
||||||
|
>
|
||||||
<MoreHorizontal size={24} />
|
<MoreHorizontal size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Nip05 from '@/components/Nip05'
|
|||||||
import UserAvatar from '@/components/UserAvatar'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
import Username from '@/components/Username'
|
import Username from '@/components/Username'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { userIdToPubkey } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import FollowingBadge from '../FollowingBadge'
|
import FollowingBadge from '../FollowingBadge'
|
||||||
@@ -20,7 +20,7 @@ export default function UserItem({
|
|||||||
showFollowingBadge?: boolean
|
showFollowingBadge?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
|
const pubkey = useMemo(() => Pubkey.tryFromString(userId)?.hex ?? userId, [userId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex gap-2 items-center h-14', className)}>
|
<div className={cn('flex gap-2 items-center h-14', className)}>
|
||||||
|
|||||||
313
src/domain/content/BookmarkList.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { Event, kinds } from 'nostr-tools'
|
||||||
|
import { EventId, Pubkey, Timestamp } from '../shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of bookmarked item
|
||||||
|
*/
|
||||||
|
export type BookmarkType = 'event' | 'replaceable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bookmarked item
|
||||||
|
*/
|
||||||
|
export type BookmarkEntry = {
|
||||||
|
type: BookmarkType
|
||||||
|
id: string // event id or 'a' tag coordinate
|
||||||
|
pubkey?: Pubkey
|
||||||
|
relayHint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a bookmark operation
|
||||||
|
*/
|
||||||
|
export type BookmarkListChange =
|
||||||
|
| { type: 'added'; entry: BookmarkEntry }
|
||||||
|
| { type: 'removed'; id: string }
|
||||||
|
| { type: 'no_change' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BookmarkList Aggregate
|
||||||
|
*
|
||||||
|
* Represents a user's bookmark list (kind 10003 in Nostr).
|
||||||
|
* Supports both regular events (e tags) and replaceable events (a tags).
|
||||||
|
*
|
||||||
|
* Invariants:
|
||||||
|
* - No duplicate entries
|
||||||
|
* - Event IDs and coordinates must be valid
|
||||||
|
*/
|
||||||
|
export class BookmarkList {
|
||||||
|
private readonly _entries: Map<string, BookmarkEntry>
|
||||||
|
private readonly _content: string
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly _owner: Pubkey,
|
||||||
|
entries: BookmarkEntry[],
|
||||||
|
content: string = ''
|
||||||
|
) {
|
||||||
|
this._entries = new Map()
|
||||||
|
for (const entry of entries) {
|
||||||
|
this._entries.set(entry.id, entry)
|
||||||
|
}
|
||||||
|
this._content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an empty BookmarkList for a user
|
||||||
|
*/
|
||||||
|
static empty(owner: Pubkey): BookmarkList {
|
||||||
|
return new BookmarkList(owner, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct a BookmarkList from a Nostr kind 10003 event
|
||||||
|
*/
|
||||||
|
static fromEvent(event: Event): BookmarkList {
|
||||||
|
if (event.kind !== kinds.BookmarkList) {
|
||||||
|
throw new Error(`Expected kind ${kinds.BookmarkList}, got ${event.kind}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = Pubkey.fromHex(event.pubkey)
|
||||||
|
const entries: BookmarkEntry[] = []
|
||||||
|
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'e' && tag[1]) {
|
||||||
|
const eventId = EventId.tryFromString(tag[1])
|
||||||
|
if (eventId) {
|
||||||
|
const pubkey = tag[2] ? Pubkey.tryFromString(tag[2]) : undefined
|
||||||
|
entries.push({
|
||||||
|
type: 'event',
|
||||||
|
id: eventId.hex,
|
||||||
|
pubkey: pubkey || undefined,
|
||||||
|
relayHint: tag[3] || undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (tag[0] === 'a' && tag[1]) {
|
||||||
|
entries.push({
|
||||||
|
type: 'replaceable',
|
||||||
|
id: tag[1],
|
||||||
|
relayHint: tag[2] || undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BookmarkList(owner, entries, event.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to create a BookmarkList from an event, returns null if invalid
|
||||||
|
*/
|
||||||
|
static tryFromEvent(event: Event | null | undefined): BookmarkList | null {
|
||||||
|
if (!event) return null
|
||||||
|
try {
|
||||||
|
return BookmarkList.fromEvent(event)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner of this bookmark list
|
||||||
|
*/
|
||||||
|
get owner(): Pubkey {
|
||||||
|
return this._owner
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of bookmarked items
|
||||||
|
*/
|
||||||
|
get count(): number {
|
||||||
|
return this._entries.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw content field
|
||||||
|
*/
|
||||||
|
get content(): string {
|
||||||
|
return this._content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all bookmark entries
|
||||||
|
*/
|
||||||
|
getEntries(): BookmarkEntry[] {
|
||||||
|
return Array.from(this._entries.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all bookmarked event IDs (e tags only)
|
||||||
|
*/
|
||||||
|
getEventIds(): string[] {
|
||||||
|
return Array.from(this._entries.values())
|
||||||
|
.filter((e) => e.type === 'event')
|
||||||
|
.map((e) => e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all bookmarked replaceable coordinates (a tags only)
|
||||||
|
*/
|
||||||
|
getReplaceableCoordinates(): string[] {
|
||||||
|
return Array.from(this._entries.values())
|
||||||
|
.filter((e) => e.type === 'replaceable')
|
||||||
|
.map((e) => e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item is bookmarked by event ID
|
||||||
|
*/
|
||||||
|
hasEventId(eventId: string): boolean {
|
||||||
|
return this._entries.has(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a replaceable event is bookmarked by coordinate
|
||||||
|
*/
|
||||||
|
hasCoordinate(coordinate: string): boolean {
|
||||||
|
return this._entries.has(coordinate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any form of the item is bookmarked
|
||||||
|
*/
|
||||||
|
isBookmarked(idOrCoordinate: string): boolean {
|
||||||
|
return this._entries.has(idOrCoordinate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an event bookmark
|
||||||
|
*
|
||||||
|
* @returns BookmarkListChange indicating what changed
|
||||||
|
*/
|
||||||
|
addEvent(eventId: EventId, pubkey?: Pubkey, relayHint?: string): BookmarkListChange {
|
||||||
|
const id = eventId.hex
|
||||||
|
|
||||||
|
if (this._entries.has(id)) {
|
||||||
|
return { type: 'no_change' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: BookmarkEntry = {
|
||||||
|
type: 'event',
|
||||||
|
id,
|
||||||
|
pubkey,
|
||||||
|
relayHint
|
||||||
|
}
|
||||||
|
this._entries.set(id, entry)
|
||||||
|
return { type: 'added', entry }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a replaceable event bookmark by coordinate
|
||||||
|
*
|
||||||
|
* @param coordinate The 'a' tag coordinate (kind:pubkey:d-tag)
|
||||||
|
* @returns BookmarkListChange indicating what changed
|
||||||
|
*/
|
||||||
|
addReplaceable(coordinate: string, relayHint?: string): BookmarkListChange {
|
||||||
|
if (this._entries.has(coordinate)) {
|
||||||
|
return { type: 'no_change' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: BookmarkEntry = {
|
||||||
|
type: 'replaceable',
|
||||||
|
id: coordinate,
|
||||||
|
relayHint
|
||||||
|
}
|
||||||
|
this._entries.set(coordinate, entry)
|
||||||
|
return { type: 'added', entry }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a bookmark from a Nostr event
|
||||||
|
*
|
||||||
|
* @returns BookmarkListChange indicating what changed
|
||||||
|
*/
|
||||||
|
addFromEvent(event: Event): BookmarkListChange {
|
||||||
|
// Check if replaceable event
|
||||||
|
if (this.isReplaceableKind(event.kind)) {
|
||||||
|
const dTag = event.tags.find((t) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||||
|
return this.addReplaceable(coordinate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular event
|
||||||
|
const eventId = EventId.tryFromString(event.id)
|
||||||
|
if (!eventId) return { type: 'no_change' }
|
||||||
|
|
||||||
|
const pubkey = Pubkey.tryFromString(event.pubkey)
|
||||||
|
return this.addEvent(eventId, pubkey || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a bookmark by ID or coordinate
|
||||||
|
*
|
||||||
|
* @returns BookmarkListChange indicating what changed
|
||||||
|
*/
|
||||||
|
remove(idOrCoordinate: string): BookmarkListChange {
|
||||||
|
if (!this._entries.has(idOrCoordinate)) {
|
||||||
|
return { type: 'no_change' }
|
||||||
|
}
|
||||||
|
|
||||||
|
this._entries.delete(idOrCoordinate)
|
||||||
|
return { type: 'removed', id: idOrCoordinate }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a bookmark by event
|
||||||
|
*/
|
||||||
|
removeFromEvent(event: Event): BookmarkListChange {
|
||||||
|
// Check if replaceable event
|
||||||
|
if (this.isReplaceableKind(event.kind)) {
|
||||||
|
const dTag = event.tags.find((t) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||||
|
return this.remove(coordinate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.remove(event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a kind is replaceable
|
||||||
|
*/
|
||||||
|
private isReplaceableKind(kind: number): boolean {
|
||||||
|
return (kind >= 10000 && kind < 20000) || (kind >= 30000 && kind < 40000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to Nostr event tags format
|
||||||
|
*/
|
||||||
|
toTags(): string[][] {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
for (const entry of this._entries.values()) {
|
||||||
|
if (entry.type === 'event') {
|
||||||
|
const tag = ['e', entry.id]
|
||||||
|
if (entry.pubkey) {
|
||||||
|
tag.push(entry.pubkey.hex)
|
||||||
|
if (entry.relayHint) {
|
||||||
|
tag.push(entry.relayHint)
|
||||||
|
}
|
||||||
|
} else if (entry.relayHint) {
|
||||||
|
tag.push('', entry.relayHint)
|
||||||
|
}
|
||||||
|
tags.push(tag)
|
||||||
|
} else {
|
||||||
|
const tag = ['a', entry.id]
|
||||||
|
if (entry.relayHint) {
|
||||||
|
tag.push(entry.relayHint)
|
||||||
|
}
|
||||||
|
tags.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to a draft event for publishing
|
||||||
|
*/
|
||||||
|
toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
|
||||||
|
return {
|
||||||
|
kind: kinds.BookmarkList,
|
||||||
|
content: this._content,
|
||||||
|
created_at: Timestamp.now().unix,
|
||||||
|
tags: this.toTags()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/domain/content/PinList.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { Event, kinds } from 'nostr-tools'
|
||||||
|
import { EventId, Pubkey, Timestamp } from '../shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of pinned notes allowed
|
||||||
|
*/
|
||||||
|
export const MAX_PINNED_NOTES = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pinned note entry
|
||||||
|
*/
|
||||||
|
export type PinEntry = {
|
||||||
|
eventId: EventId
|
||||||
|
pubkey?: Pubkey
|
||||||
|
relayHint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a pin operation
|
||||||
|
*/
|
||||||
|
export type PinListChange =
|
||||||
|
| { type: 'pinned'; entry: PinEntry }
|
||||||
|
| { type: 'unpinned'; eventId: string }
|
||||||
|
| { type: 'no_change' }
|
||||||
|
| { type: 'limit_exceeded'; removed: PinEntry[] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when trying to pin non-own content
|
||||||
|
*/
|
||||||
|
export class CannotPinOthersContentError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Cannot pin content from other users')
|
||||||
|
this.name = 'CannotPinOthersContentError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when trying to pin non-note content
|
||||||
|
*/
|
||||||
|
export class CanOnlyPinNotesError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Can only pin short text notes')
|
||||||
|
this.name = 'CanOnlyPinNotesError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PinList Aggregate
|
||||||
|
*
|
||||||
|
* Represents a user's pinned notes list (kind 10001 in Nostr).
|
||||||
|
* Users can pin their own short text notes to highlight them on their profile.
|
||||||
|
*
|
||||||
|
* Invariants:
|
||||||
|
* - Can only pin own notes (same pubkey)
|
||||||
|
* - Can only pin short text notes (kind 1)
|
||||||
|
* - Maximum of MAX_PINNED_NOTES entries (oldest removed when exceeded)
|
||||||
|
* - No duplicate entries
|
||||||
|
*/
|
||||||
|
export class PinList {
|
||||||
|
private readonly _entries: Map<string, PinEntry>
|
||||||
|
private readonly _order: string[] // Maintains insertion order
|
||||||
|
private readonly _content: string
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly _owner: Pubkey,
|
||||||
|
entries: PinEntry[],
|
||||||
|
content: string = ''
|
||||||
|
) {
|
||||||
|
this._entries = new Map()
|
||||||
|
this._order = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
this._entries.set(entry.eventId.hex, entry)
|
||||||
|
this._order.push(entry.eventId.hex)
|
||||||
|
}
|
||||||
|
this._content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an empty PinList for a user
|
||||||
|
*/
|
||||||
|
static empty(owner: Pubkey): PinList {
|
||||||
|
return new PinList(owner, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct a PinList from a Nostr kind 10001 event
|
||||||
|
*/
|
||||||
|
static fromEvent(event: Event): PinList {
|
||||||
|
if (event.kind !== kinds.Pinlist) {
|
||||||
|
throw new Error(`Expected kind ${kinds.Pinlist}, got ${event.kind}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = Pubkey.fromHex(event.pubkey)
|
||||||
|
const entries: PinEntry[] = []
|
||||||
|
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'e' && tag[1]) {
|
||||||
|
const eventId = EventId.tryFromString(tag[1])
|
||||||
|
if (eventId && !entries.some((e) => e.eventId.hex === eventId.hex)) {
|
||||||
|
const pubkey = tag[2] ? Pubkey.tryFromString(tag[2]) : undefined
|
||||||
|
entries.push({
|
||||||
|
eventId,
|
||||||
|
pubkey: pubkey || undefined,
|
||||||
|
relayHint: tag[3] || undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PinList(owner, entries, event.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to create a PinList from an event, returns null if invalid
|
||||||
|
*/
|
||||||
|
static tryFromEvent(event: Event | null | undefined): PinList | null {
|
||||||
|
if (!event) return null
|
||||||
|
try {
|
||||||
|
return PinList.fromEvent(event)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner of this pin list
|
||||||
|
*/
|
||||||
|
get owner(): Pubkey {
|
||||||
|
return this._owner
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of pinned notes
|
||||||
|
*/
|
||||||
|
get count(): number {
|
||||||
|
return this._entries.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the pin list is at maximum capacity
|
||||||
|
*/
|
||||||
|
get isFull(): boolean {
|
||||||
|
return this._entries.size >= MAX_PINNED_NOTES
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw content field
|
||||||
|
*/
|
||||||
|
get content(): string {
|
||||||
|
return this._content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pinned entries in order
|
||||||
|
*/
|
||||||
|
getEntries(): PinEntry[] {
|
||||||
|
return this._order.map((id) => this._entries.get(id)!).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pinned event IDs
|
||||||
|
*/
|
||||||
|
getEventIds(): string[] {
|
||||||
|
return [...this._order]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pinned event IDs as a Set for fast lookup
|
||||||
|
*/
|
||||||
|
getEventIdSet(): Set<string> {
|
||||||
|
return new Set(this._order)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a note is pinned
|
||||||
|
*/
|
||||||
|
isPinned(eventId: string): boolean {
|
||||||
|
return this._entries.has(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin a note
|
||||||
|
*
|
||||||
|
* @throws CannotPinOthersContentError if note is from another user
|
||||||
|
* @throws CanOnlyPinNotesError if event is not a short text note
|
||||||
|
* @returns PinListChange indicating what changed
|
||||||
|
*/
|
||||||
|
pin(event: Event): PinListChange {
|
||||||
|
// Validate: only own notes
|
||||||
|
if (event.pubkey !== this._owner.hex) {
|
||||||
|
throw new CannotPinOthersContentError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: only short text notes
|
||||||
|
if (event.kind !== kinds.ShortTextNote) {
|
||||||
|
throw new CanOnlyPinNotesError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = EventId.fromHex(event.id)
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
if (this._entries.has(eventId.hex)) {
|
||||||
|
return { type: 'no_change' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: PinEntry = {
|
||||||
|
eventId,
|
||||||
|
pubkey: this._owner,
|
||||||
|
relayHint: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check capacity and remove oldest if needed
|
||||||
|
const removed: PinEntry[] = []
|
||||||
|
while (this._entries.size >= MAX_PINNED_NOTES) {
|
||||||
|
const oldestId = this._order.shift()
|
||||||
|
if (oldestId) {
|
||||||
|
const oldEntry = this._entries.get(oldestId)
|
||||||
|
if (oldEntry) {
|
||||||
|
removed.push(oldEntry)
|
||||||
|
}
|
||||||
|
this._entries.delete(oldestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new pin
|
||||||
|
this._entries.set(eventId.hex, entry)
|
||||||
|
this._order.push(eventId.hex)
|
||||||
|
|
||||||
|
if (removed.length > 0) {
|
||||||
|
return { type: 'limit_exceeded', removed }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'pinned', entry }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpin a note
|
||||||
|
*
|
||||||
|
* @returns PinListChange indicating what changed
|
||||||
|
*/
|
||||||
|
unpin(eventId: string): PinListChange {
|
||||||
|
if (!this._entries.has(eventId)) {
|
||||||
|
return { type: 'no_change' }
|
||||||
|
}
|
||||||
|
|
||||||
|
this._entries.delete(eventId)
|
||||||
|
const index = this._order.indexOf(eventId)
|
||||||
|
if (index !== -1) {
|
||||||
|
this._order.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'unpinned', eventId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpin by event
|
||||||
|
*/
|
||||||
|
unpinEvent(event: Event): PinListChange {
|
||||||
|
return this.unpin(event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to Nostr event tags format
|
||||||
|
*/
|
||||||
|
toTags(): string[][] {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
for (const id of this._order) {
|
||||||
|
const entry = this._entries.get(id)
|
||||||
|
if (entry) {
|
||||||
|
const tag = ['e', entry.eventId.hex]
|
||||||
|
if (entry.pubkey) {
|
||||||
|
tag.push(entry.pubkey.hex)
|
||||||
|
if (entry.relayHint) {
|
||||||
|
tag.push(entry.relayHint)
|
||||||
|
}
|
||||||
|
} else if (entry.relayHint) {
|
||||||
|
tag.push('', entry.relayHint)
|
||||||
|
}
|
||||||
|
tags.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to a draft event for publishing
|
||||||
|
*/
|
||||||
|
toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
|
||||||
|
return {
|
||||||
|
kind: kinds.Pinlist,
|
||||||
|
content: this._content,
|
||||||
|
created_at: Timestamp.now().unix,
|
||||||
|
tags: this.toTags()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { Event, kinds } from 'nostr-tools'
|
|||||||
import { Note } from './Note'
|
import { Note } from './Note'
|
||||||
import { Reaction } from './Reaction'
|
import { Reaction } from './Reaction'
|
||||||
import { Repost } from './Repost'
|
import { Repost } from './Repost'
|
||||||
|
import { BookmarkList } from './BookmarkList'
|
||||||
|
import { PinList } from './PinList'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Note Adapters
|
// Note Adapters
|
||||||
@@ -173,3 +175,53 @@ export const parseContentEvent = (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BookmarkList Adapters
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Nostr event to a BookmarkList domain object
|
||||||
|
*/
|
||||||
|
export const toBookmarkList = (event: Event): BookmarkList => {
|
||||||
|
return BookmarkList.fromEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to create a BookmarkList from an event, returns null if invalid
|
||||||
|
*/
|
||||||
|
export const tryToBookmarkList = (event: Event | null | undefined): BookmarkList | null => {
|
||||||
|
return BookmarkList.tryFromEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an event is a bookmark list
|
||||||
|
*/
|
||||||
|
export const isBookmarkListEvent = (event: Event): boolean => {
|
||||||
|
return event.kind === kinds.BookmarkList
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PinList Adapters
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Nostr event to a PinList domain object
|
||||||
|
*/
|
||||||
|
export const toPinList = (event: Event): PinList => {
|
||||||
|
return PinList.fromEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to create a PinList from an event, returns null if invalid
|
||||||
|
*/
|
||||||
|
export const tryToPinList = (event: Event | null | undefined): PinList | null => {
|
||||||
|
return PinList.tryFromEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an event is a pin list
|
||||||
|
*/
|
||||||
|
export const isPinListEvent = (event: Event): boolean => {
|
||||||
|
return event.kind === kinds.Pinlist
|
||||||
|
}
|
||||||
|
|||||||
166
src/domain/content/events.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Pubkey, EventId, DomainEvent } from '../shared'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bookmark Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when an event is bookmarked
|
||||||
|
*/
|
||||||
|
export class EventBookmarked extends DomainEvent {
|
||||||
|
readonly eventType = 'content.event_bookmarked'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly actor: Pubkey,
|
||||||
|
readonly bookmarkedEventId: string,
|
||||||
|
readonly bookmarkType: 'event' | 'replaceable'
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when an event is removed from bookmarks
|
||||||
|
*/
|
||||||
|
export class EventUnbookmarked extends DomainEvent {
|
||||||
|
readonly eventType = 'content.event_unbookmarked'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly actor: Pubkey,
|
||||||
|
readonly unbookmarkedEventId: string
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a bookmark list is published
|
||||||
|
*/
|
||||||
|
export class BookmarkListPublished extends DomainEvent {
|
||||||
|
readonly eventType = 'content.bookmark_list_published'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly owner: Pubkey,
|
||||||
|
readonly bookmarkCount: number
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pin Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a note is pinned
|
||||||
|
*/
|
||||||
|
export class NotePinned extends DomainEvent {
|
||||||
|
readonly eventType = 'content.note_pinned'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly actor: Pubkey,
|
||||||
|
readonly pinnedEventId: EventId
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a note is unpinned
|
||||||
|
*/
|
||||||
|
export class NoteUnpinned extends DomainEvent {
|
||||||
|
readonly eventType = 'content.note_unpinned'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly actor: Pubkey,
|
||||||
|
readonly unpinnedEventId: string
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when old pins are removed due to limit
|
||||||
|
*/
|
||||||
|
export class PinsLimitExceeded extends DomainEvent {
|
||||||
|
readonly eventType = 'content.pins_limit_exceeded'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly actor: Pubkey,
|
||||||
|
readonly removedEventIds: string[]
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a pin list is published
|
||||||
|
*/
|
||||||
|
export class PinListPublished extends DomainEvent {
|
||||||
|
readonly eventType = 'content.pin_list_published'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly owner: Pubkey,
|
||||||
|
readonly pinCount: number
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Reaction Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a reaction is added to content
|
||||||
|
*/
|
||||||
|
export class ReactionAdded extends DomainEvent {
|
||||||
|
readonly eventType = 'content.reaction_added'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly actor: Pubkey,
|
||||||
|
readonly targetEventId: EventId,
|
||||||
|
readonly targetAuthor: Pubkey,
|
||||||
|
readonly emoji: string,
|
||||||
|
readonly isLike: boolean
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Repost Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when content is reposted
|
||||||
|
*/
|
||||||
|
export class ContentReposted extends DomainEvent {
|
||||||
|
readonly eventType = 'content.reposted'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly actor: Pubkey,
|
||||||
|
readonly originalEventId: EventId,
|
||||||
|
readonly originalAuthor: Pubkey
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Event Types Union
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type of all content domain events
|
||||||
|
*/
|
||||||
|
export type ContentDomainEvent =
|
||||||
|
| EventBookmarked
|
||||||
|
| EventUnbookmarked
|
||||||
|
| BookmarkListPublished
|
||||||
|
| NotePinned
|
||||||
|
| NoteUnpinned
|
||||||
|
| PinsLimitExceeded
|
||||||
|
| PinListPublished
|
||||||
|
| ReactionAdded
|
||||||
|
| ContentReposted
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Content Bounded Context
|
* Content Bounded Context
|
||||||
*
|
*
|
||||||
* Handles notes, reactions, reposts, and other content types.
|
* Handles notes, reactions, reposts, bookmarks, pins, and other content types.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Entities
|
// Entities
|
||||||
@@ -13,6 +13,13 @@ export type { ReactionType, CustomEmoji } from './Reaction'
|
|||||||
|
|
||||||
export { Repost } from './Repost'
|
export { Repost } from './Repost'
|
||||||
|
|
||||||
|
// Aggregates
|
||||||
|
export { BookmarkList } from './BookmarkList'
|
||||||
|
export type { BookmarkType, BookmarkEntry, BookmarkListChange } from './BookmarkList'
|
||||||
|
|
||||||
|
export { PinList, MAX_PINNED_NOTES, CannotPinOthersContentError, CanOnlyPinNotesError } from './PinList'
|
||||||
|
export type { PinEntry, PinListChange } from './PinList'
|
||||||
|
|
||||||
// Errors
|
// Errors
|
||||||
export {
|
export {
|
||||||
InvalidContentError,
|
InvalidContentError,
|
||||||
@@ -22,6 +29,23 @@ export {
|
|||||||
ContentTooLargeError
|
ContentTooLargeError
|
||||||
} from './errors'
|
} from './errors'
|
||||||
|
|
||||||
|
// Domain Events
|
||||||
|
export {
|
||||||
|
EventBookmarked,
|
||||||
|
EventUnbookmarked,
|
||||||
|
BookmarkListPublished,
|
||||||
|
NotePinned,
|
||||||
|
NoteUnpinned,
|
||||||
|
PinsLimitExceeded,
|
||||||
|
PinListPublished,
|
||||||
|
ReactionAdded,
|
||||||
|
ContentReposted
|
||||||
|
} from './events'
|
||||||
|
export type { ContentDomainEvent } from './events'
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
export type { BookmarkListRepository, PinListRepository } from './repositories'
|
||||||
|
|
||||||
// Adapters for migration
|
// Adapters for migration
|
||||||
export {
|
export {
|
||||||
// Note adapters
|
// Note adapters
|
||||||
@@ -41,6 +65,14 @@ export {
|
|||||||
tryToRepost,
|
tryToRepost,
|
||||||
isRepostEvent,
|
isRepostEvent,
|
||||||
toReposts,
|
toReposts,
|
||||||
|
// BookmarkList adapters
|
||||||
|
toBookmarkList,
|
||||||
|
tryToBookmarkList,
|
||||||
|
isBookmarkListEvent,
|
||||||
|
// PinList adapters
|
||||||
|
toPinList,
|
||||||
|
tryToPinList,
|
||||||
|
isPinListEvent,
|
||||||
// Content type detection
|
// Content type detection
|
||||||
getContentType,
|
getContentType,
|
||||||
parseContentEvent
|
parseContentEvent
|
||||||
|
|||||||
47
src/domain/content/repositories.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Pubkey } from '../shared'
|
||||||
|
import { BookmarkList } from './BookmarkList'
|
||||||
|
import { PinList } from './PinList'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for BookmarkList aggregate
|
||||||
|
*
|
||||||
|
* Implementations should handle:
|
||||||
|
* - Local caching (IndexedDB)
|
||||||
|
* - Remote fetching from relays
|
||||||
|
* - Event publishing
|
||||||
|
*/
|
||||||
|
export interface BookmarkListRepository {
|
||||||
|
/**
|
||||||
|
* Find the bookmark list for a user
|
||||||
|
* Should check cache first, then fetch from relays if not found
|
||||||
|
*/
|
||||||
|
findByOwner(pubkey: Pubkey): Promise<BookmarkList | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a bookmark list
|
||||||
|
* Should publish to relays and update local cache
|
||||||
|
*/
|
||||||
|
save(bookmarkList: BookmarkList): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for PinList aggregate
|
||||||
|
*
|
||||||
|
* Implementations should handle:
|
||||||
|
* - Local caching (IndexedDB)
|
||||||
|
* - Remote fetching from relays
|
||||||
|
* - Event publishing
|
||||||
|
*/
|
||||||
|
export interface PinListRepository {
|
||||||
|
/**
|
||||||
|
* Find the pin list for a user
|
||||||
|
* Should check cache first, then fetch from relays if not found
|
||||||
|
*/
|
||||||
|
findByOwner(pubkey: Pubkey): Promise<PinList | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a pin list
|
||||||
|
* Should publish to relays and update local cache
|
||||||
|
*/
|
||||||
|
save(pinList: PinList): Promise<void>
|
||||||
|
}
|
||||||
256
src/domain/feed/ContentFilter.test.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ContentFilter } from './ContentFilter'
|
||||||
|
import type { Event } from 'nostr-tools'
|
||||||
|
|
||||||
|
describe('ContentFilter', () => {
|
||||||
|
// Helper to create mock events
|
||||||
|
const createEvent = (overrides: Partial<Event> = {}): Event => ({
|
||||||
|
id: 'a'.repeat(64),
|
||||||
|
pubkey: 'b'.repeat(64),
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: 'test content',
|
||||||
|
sig: 'c'.repeat(128),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('factory methods', () => {
|
||||||
|
it('creates default filter with sensible defaults', () => {
|
||||||
|
const filter = ContentFilter.default()
|
||||||
|
|
||||||
|
expect(filter.hideMutedUsers).toBe(true)
|
||||||
|
expect(filter.hideContentMentioningMuted).toBe(true)
|
||||||
|
expect(filter.hideUntrustedUsers).toBe(false)
|
||||||
|
expect(filter.hideReplies).toBe(false)
|
||||||
|
expect(filter.hideReposts).toBe(false)
|
||||||
|
expect(filter.allowedKinds).toEqual([])
|
||||||
|
expect(filter.nsfwPolicy).toBe('hide_content')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates filter from preferences', () => {
|
||||||
|
const filter = ContentFilter.fromPreferences({
|
||||||
|
hideMutedUsers: false,
|
||||||
|
hideReplies: true,
|
||||||
|
nsfwPolicy: 'show'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(filter.hideMutedUsers).toBe(false)
|
||||||
|
expect(filter.hideReplies).toBe(true)
|
||||||
|
expect(filter.nsfwPolicy).toBe('show')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses defaults for missing preferences', () => {
|
||||||
|
const filter = ContentFilter.fromPreferences({})
|
||||||
|
|
||||||
|
expect(filter.hideMutedUsers).toBe(true)
|
||||||
|
expect(filter.nsfwPolicy).toBe('hide_content')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isKindAllowed', () => {
|
||||||
|
it('allows all kinds when allowedKinds is empty', () => {
|
||||||
|
const filter = ContentFilter.default()
|
||||||
|
|
||||||
|
expect(filter.isKindAllowed(1)).toBe(true)
|
||||||
|
expect(filter.isKindAllowed(6)).toBe(true)
|
||||||
|
expect(filter.isKindAllowed(30023)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only allows specified kinds', () => {
|
||||||
|
const filter = ContentFilter.default().withAllowedKinds([1, 6])
|
||||||
|
|
||||||
|
expect(filter.isKindAllowed(1)).toBe(true)
|
||||||
|
expect(filter.isKindAllowed(6)).toBe(true)
|
||||||
|
expect(filter.isKindAllowed(7)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldShow', () => {
|
||||||
|
const mutedPubkeys = new Set(['muted'.repeat(8)])
|
||||||
|
const trustedPubkeys = new Set(['trusted'.repeat(8)])
|
||||||
|
const deletedEventIds = new Set(['deleted'.repeat(8)])
|
||||||
|
|
||||||
|
it('shows normal events', () => {
|
||||||
|
const filter = ContentFilter.default()
|
||||||
|
const event = createEvent()
|
||||||
|
const context = { mutedPubkeys: new Set<string>() }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides events from muted authors', () => {
|
||||||
|
const filter = ContentFilter.default()
|
||||||
|
const event = createEvent({ pubkey: 'muted'.repeat(8) })
|
||||||
|
const context = { mutedPubkeys }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(false)
|
||||||
|
expect(result.reason).toBe('muted_author')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows events from muted authors when hideMutedUsers is false', () => {
|
||||||
|
const filter = ContentFilter.default().withHideMutedUsers(false)
|
||||||
|
const event = createEvent({ pubkey: 'muted'.repeat(8) })
|
||||||
|
const context = { mutedPubkeys }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides events mentioning muted users', () => {
|
||||||
|
const filter = ContentFilter.default()
|
||||||
|
const event = createEvent({
|
||||||
|
tags: [['p', 'muted'.repeat(8)]]
|
||||||
|
})
|
||||||
|
const context = { mutedPubkeys }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(false)
|
||||||
|
expect(result.reason).toBe('mentions_muted_user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides deleted events', () => {
|
||||||
|
const filter = ContentFilter.default()
|
||||||
|
const event = createEvent({ id: 'deleted'.repeat(8) })
|
||||||
|
const context = { mutedPubkeys: new Set<string>(), deletedEventIds }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(false)
|
||||||
|
expect(result.reason).toBe('deleted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides untrusted authors when enabled', () => {
|
||||||
|
const filter = ContentFilter.default().withHideUntrustedUsers(true)
|
||||||
|
const event = createEvent({ pubkey: 'stranger'.repeat(8) })
|
||||||
|
const context = { mutedPubkeys: new Set<string>(), trustedPubkeys }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(false)
|
||||||
|
expect(result.reason).toBe('untrusted_author')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows trusted authors when hiding untrusted', () => {
|
||||||
|
const filter = ContentFilter.default().withHideUntrustedUsers(true)
|
||||||
|
const event = createEvent({ pubkey: 'trusted'.repeat(8) })
|
||||||
|
const context = { mutedPubkeys: new Set<string>(), trustedPubkeys }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides replies when enabled', () => {
|
||||||
|
const filter = ContentFilter.default().withHideReplies(true)
|
||||||
|
const event = createEvent({
|
||||||
|
tags: [['e', 'someevent'.repeat(8), '', 'reply']]
|
||||||
|
})
|
||||||
|
const context = { mutedPubkeys: new Set<string>() }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(false)
|
||||||
|
expect(result.reason).toBe('reply_filtered')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides reposts when enabled', () => {
|
||||||
|
const filter = ContentFilter.default().withHideReposts(true)
|
||||||
|
const event = createEvent({ kind: 6 })
|
||||||
|
const context = { mutedPubkeys: new Set<string>() }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(false)
|
||||||
|
expect(result.reason).toBe('repost_filtered')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides events with disallowed kinds', () => {
|
||||||
|
const filter = ContentFilter.default().withAllowedKinds([1])
|
||||||
|
const event = createEvent({ kind: 6 })
|
||||||
|
const context = { mutedPubkeys: new Set<string>() }
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(false)
|
||||||
|
expect(result.reason).toBe('kind_not_allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows pinned events even from muted authors', () => {
|
||||||
|
const filter = ContentFilter.default()
|
||||||
|
const eventId = 'pinned'.repeat(8)
|
||||||
|
const event = createEvent({ id: eventId, pubkey: 'muted'.repeat(8) })
|
||||||
|
const context = {
|
||||||
|
mutedPubkeys,
|
||||||
|
pinnedEventIds: new Set([eventId])
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = filter.shouldShow(event, context)
|
||||||
|
|
||||||
|
expect(result.shouldShow).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('immutable modifications', () => {
|
||||||
|
it('withHideMutedUsers returns new instance', () => {
|
||||||
|
const filter1 = ContentFilter.default()
|
||||||
|
const filter2 = filter1.withHideMutedUsers(false)
|
||||||
|
|
||||||
|
expect(filter1.hideMutedUsers).toBe(true)
|
||||||
|
expect(filter2.hideMutedUsers).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('withHideReplies returns new instance', () => {
|
||||||
|
const filter1 = ContentFilter.default()
|
||||||
|
const filter2 = filter1.withHideReplies(true)
|
||||||
|
|
||||||
|
expect(filter1.hideReplies).toBe(false)
|
||||||
|
expect(filter2.hideReplies).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('withAllowedKinds returns new instance', () => {
|
||||||
|
const filter1 = ContentFilter.default()
|
||||||
|
const filter2 = filter1.withAllowedKinds([1, 6, 7])
|
||||||
|
|
||||||
|
expect(filter1.allowedKinds).toEqual([])
|
||||||
|
expect(filter2.allowedKinds).toEqual([1, 6, 7])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('withNsfwPolicy returns new instance', () => {
|
||||||
|
const filter1 = ContentFilter.default()
|
||||||
|
const filter2 = filter1.withNsfwPolicy('show')
|
||||||
|
|
||||||
|
expect(filter1.nsfwPolicy).toBe('hide_content')
|
||||||
|
expect(filter2.nsfwPolicy).toBe('show')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('returns true for identical filters', () => {
|
||||||
|
const filter1 = ContentFilter.default()
|
||||||
|
const filter2 = ContentFilter.default()
|
||||||
|
|
||||||
|
expect(filter1.equals(filter2)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for different settings', () => {
|
||||||
|
const filter1 = ContentFilter.default()
|
||||||
|
const filter2 = ContentFilter.default().withHideReplies(true)
|
||||||
|
|
||||||
|
expect(filter1.equals(filter2)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for different allowed kinds', () => {
|
||||||
|
const filter1 = ContentFilter.default().withAllowedKinds([1])
|
||||||
|
const filter2 = ContentFilter.default().withAllowedKinds([1, 6])
|
||||||
|
|
||||||
|
expect(filter1.equals(filter2)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
323
src/domain/feed/ContentFilter.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NSFW display policy options
|
||||||
|
*/
|
||||||
|
export type NsfwDisplayPolicy = 'hide' | 'hide_content' | 'show'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context required for filtering decisions
|
||||||
|
*/
|
||||||
|
export interface FilterContext {
|
||||||
|
mutedPubkeys: Set<string>
|
||||||
|
trustedPubkeys?: Set<string>
|
||||||
|
deletedEventIds?: Set<string>
|
||||||
|
currentUserPubkey?: string
|
||||||
|
pinnedEventIds?: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a filter check with reason
|
||||||
|
*/
|
||||||
|
export type FilterResult = {
|
||||||
|
shouldShow: boolean
|
||||||
|
reason?: FilterReason
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reason why an event was filtered
|
||||||
|
*/
|
||||||
|
export type FilterReason =
|
||||||
|
| 'muted_author'
|
||||||
|
| 'mentions_muted_user'
|
||||||
|
| 'untrusted_author'
|
||||||
|
| 'deleted'
|
||||||
|
| 'reply_filtered'
|
||||||
|
| 'repost_filtered'
|
||||||
|
| 'nsfw_hidden'
|
||||||
|
| 'kind_not_allowed'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContentFilter Value Object
|
||||||
|
*
|
||||||
|
* Encapsulates all filtering criteria for timeline content.
|
||||||
|
* Immutable - all modifications return new instances.
|
||||||
|
*/
|
||||||
|
export class ContentFilter {
|
||||||
|
private constructor(
|
||||||
|
private readonly _hideMutedUsers: boolean,
|
||||||
|
private readonly _hideContentMentioningMuted: boolean,
|
||||||
|
private readonly _hideUntrustedUsers: boolean,
|
||||||
|
private readonly _hideReplies: boolean,
|
||||||
|
private readonly _hideReposts: boolean,
|
||||||
|
private readonly _allowedKinds: readonly number[],
|
||||||
|
private readonly _nsfwPolicy: NsfwDisplayPolicy
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default content filter with sensible defaults
|
||||||
|
*/
|
||||||
|
static default(): ContentFilter {
|
||||||
|
return new ContentFilter(
|
||||||
|
true, // hideMutedUsers
|
||||||
|
true, // hideContentMentioningMuted
|
||||||
|
false, // hideUntrustedUsers
|
||||||
|
false, // hideReplies
|
||||||
|
false, // hideReposts
|
||||||
|
[], // allowedKinds (empty = allow all)
|
||||||
|
'hide_content' // nsfwPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create filter from user preferences
|
||||||
|
*/
|
||||||
|
static fromPreferences(prefs: {
|
||||||
|
hideMutedUsers?: boolean
|
||||||
|
hideContentMentioningMuted?: boolean
|
||||||
|
hideUntrustedUsers?: boolean
|
||||||
|
hideReplies?: boolean
|
||||||
|
hideReposts?: boolean
|
||||||
|
allowedKinds?: number[]
|
||||||
|
nsfwPolicy?: NsfwDisplayPolicy
|
||||||
|
}): ContentFilter {
|
||||||
|
return new ContentFilter(
|
||||||
|
prefs.hideMutedUsers ?? true,
|
||||||
|
prefs.hideContentMentioningMuted ?? true,
|
||||||
|
prefs.hideUntrustedUsers ?? false,
|
||||||
|
prefs.hideReplies ?? false,
|
||||||
|
prefs.hideReposts ?? false,
|
||||||
|
prefs.allowedKinds ?? [],
|
||||||
|
prefs.nsfwPolicy ?? 'hide_content'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get hideMutedUsers(): boolean {
|
||||||
|
return this._hideMutedUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
get hideContentMentioningMuted(): boolean {
|
||||||
|
return this._hideContentMentioningMuted
|
||||||
|
}
|
||||||
|
|
||||||
|
get hideUntrustedUsers(): boolean {
|
||||||
|
return this._hideUntrustedUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
get hideReplies(): boolean {
|
||||||
|
return this._hideReplies
|
||||||
|
}
|
||||||
|
|
||||||
|
get hideReposts(): boolean {
|
||||||
|
return this._hideReposts
|
||||||
|
}
|
||||||
|
|
||||||
|
get allowedKinds(): readonly number[] {
|
||||||
|
return this._allowedKinds
|
||||||
|
}
|
||||||
|
|
||||||
|
get nsfwPolicy(): NsfwDisplayPolicy {
|
||||||
|
return this._nsfwPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a kind is allowed by this filter
|
||||||
|
*/
|
||||||
|
isKindAllowed(kind: number): boolean {
|
||||||
|
// Empty array means all kinds allowed
|
||||||
|
if (this._allowedKinds.length === 0) return true
|
||||||
|
return this._allowedKinds.includes(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an event should be shown based on this filter and context
|
||||||
|
*/
|
||||||
|
shouldShow(event: Event, context: FilterContext): FilterResult {
|
||||||
|
// Check kind filter first
|
||||||
|
if (!this.isKindAllowed(event.kind)) {
|
||||||
|
return { shouldShow: false, reason: 'kind_not_allowed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event is pinned (pinned events bypass most filters)
|
||||||
|
if (context.pinnedEventIds?.has(event.id)) {
|
||||||
|
return { shouldShow: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check deleted
|
||||||
|
if (context.deletedEventIds?.has(event.id)) {
|
||||||
|
return { shouldShow: false, reason: 'deleted' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check muted author
|
||||||
|
if (this._hideMutedUsers && context.mutedPubkeys.has(event.pubkey)) {
|
||||||
|
return { shouldShow: false, reason: 'muted_author' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if content mentions muted users
|
||||||
|
if (this._hideContentMentioningMuted) {
|
||||||
|
const mentionedPubkeys = this.extractMentionedPubkeys(event)
|
||||||
|
for (const pk of mentionedPubkeys) {
|
||||||
|
if (context.mutedPubkeys.has(pk)) {
|
||||||
|
return { shouldShow: false, reason: 'mentions_muted_user' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check untrusted
|
||||||
|
if (this._hideUntrustedUsers && context.trustedPubkeys) {
|
||||||
|
if (!context.trustedPubkeys.has(event.pubkey)) {
|
||||||
|
return { shouldShow: false, reason: 'untrusted_author' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reply filter
|
||||||
|
if (this._hideReplies && this.isReply(event)) {
|
||||||
|
return { shouldShow: false, reason: 'reply_filtered' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check repost filter
|
||||||
|
if (this._hideReposts && this.isRepost(event)) {
|
||||||
|
return { shouldShow: false, reason: 'repost_filtered' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shouldShow: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract pubkeys mentioned in an event
|
||||||
|
*/
|
||||||
|
private extractMentionedPubkeys(event: Event): string[] {
|
||||||
|
const pubkeys: string[] = []
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'p' && tag[1]) {
|
||||||
|
pubkeys.push(tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event is a reply
|
||||||
|
*/
|
||||||
|
private isReply(event: Event): boolean {
|
||||||
|
// Check for 'e' or 'E' tags with reply marker, or just any 'e' tag
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if ((tag[0] === 'e' || tag[0] === 'E') && tag[1]) {
|
||||||
|
// If marker is 'reply' or 'root', it's a reply
|
||||||
|
if (tag[3] === 'reply' || tag[3] === 'root') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Legacy: any 'e' tag indicates reply
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event is a repost
|
||||||
|
*/
|
||||||
|
private isRepost(event: Event): boolean {
|
||||||
|
return event.kind === 6 || event.kind === 16
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immutable modification methods
|
||||||
|
withHideMutedUsers(hide: boolean): ContentFilter {
|
||||||
|
return new ContentFilter(
|
||||||
|
hide,
|
||||||
|
this._hideContentMentioningMuted,
|
||||||
|
this._hideUntrustedUsers,
|
||||||
|
this._hideReplies,
|
||||||
|
this._hideReposts,
|
||||||
|
this._allowedKinds,
|
||||||
|
this._nsfwPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
withHideContentMentioningMuted(hide: boolean): ContentFilter {
|
||||||
|
return new ContentFilter(
|
||||||
|
this._hideMutedUsers,
|
||||||
|
hide,
|
||||||
|
this._hideUntrustedUsers,
|
||||||
|
this._hideReplies,
|
||||||
|
this._hideReposts,
|
||||||
|
this._allowedKinds,
|
||||||
|
this._nsfwPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
withHideUntrustedUsers(hide: boolean): ContentFilter {
|
||||||
|
return new ContentFilter(
|
||||||
|
this._hideMutedUsers,
|
||||||
|
this._hideContentMentioningMuted,
|
||||||
|
hide,
|
||||||
|
this._hideReplies,
|
||||||
|
this._hideReposts,
|
||||||
|
this._allowedKinds,
|
||||||
|
this._nsfwPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
withHideReplies(hide: boolean): ContentFilter {
|
||||||
|
return new ContentFilter(
|
||||||
|
this._hideMutedUsers,
|
||||||
|
this._hideContentMentioningMuted,
|
||||||
|
this._hideUntrustedUsers,
|
||||||
|
hide,
|
||||||
|
this._hideReposts,
|
||||||
|
this._allowedKinds,
|
||||||
|
this._nsfwPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
withHideReposts(hide: boolean): ContentFilter {
|
||||||
|
return new ContentFilter(
|
||||||
|
this._hideMutedUsers,
|
||||||
|
this._hideContentMentioningMuted,
|
||||||
|
this._hideUntrustedUsers,
|
||||||
|
this._hideReplies,
|
||||||
|
hide,
|
||||||
|
this._allowedKinds,
|
||||||
|
this._nsfwPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
withAllowedKinds(kinds: number[]): ContentFilter {
|
||||||
|
return new ContentFilter(
|
||||||
|
this._hideMutedUsers,
|
||||||
|
this._hideContentMentioningMuted,
|
||||||
|
this._hideUntrustedUsers,
|
||||||
|
this._hideReplies,
|
||||||
|
this._hideReposts,
|
||||||
|
[...kinds],
|
||||||
|
this._nsfwPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
withNsfwPolicy(policy: NsfwDisplayPolicy): ContentFilter {
|
||||||
|
return new ContentFilter(
|
||||||
|
this._hideMutedUsers,
|
||||||
|
this._hideContentMentioningMuted,
|
||||||
|
this._hideUntrustedUsers,
|
||||||
|
this._hideReplies,
|
||||||
|
this._hideReposts,
|
||||||
|
this._allowedKinds,
|
||||||
|
policy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: ContentFilter): boolean {
|
||||||
|
if (this._hideMutedUsers !== other._hideMutedUsers) return false
|
||||||
|
if (this._hideContentMentioningMuted !== other._hideContentMentioningMuted) return false
|
||||||
|
if (this._hideUntrustedUsers !== other._hideUntrustedUsers) return false
|
||||||
|
if (this._hideReplies !== other._hideReplies) return false
|
||||||
|
if (this._hideReposts !== other._hideReposts) return false
|
||||||
|
if (this._nsfwPolicy !== other._nsfwPolicy) return false
|
||||||
|
if (this._allowedKinds.length !== other._allowedKinds.length) return false
|
||||||
|
for (let i = 0; i < this._allowedKinds.length; i++) {
|
||||||
|
if (this._allowedKinds[i] !== other._allowedKinds[i]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
254
src/domain/feed/Feed.test.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { Feed } from './Feed'
|
||||||
|
import { FeedType } from './FeedType'
|
||||||
|
import { ContentFilter } from './ContentFilter'
|
||||||
|
import { Pubkey } from '../shared/value-objects/Pubkey'
|
||||||
|
import { RelayUrl } from '../shared/value-objects/RelayUrl'
|
||||||
|
import { FeedSwitched, ContentFilterUpdated, FeedRefreshed } from './events'
|
||||||
|
|
||||||
|
describe('Feed', () => {
|
||||||
|
// Test data
|
||||||
|
const ownerPubkey = Pubkey.fromHex(
|
||||||
|
'a'.repeat(64)
|
||||||
|
)
|
||||||
|
const relayUrl1 = RelayUrl.tryCreate('wss://relay1.example.com')!
|
||||||
|
const relayUrl2 = RelayUrl.tryCreate('wss://relay2.example.com')!
|
||||||
|
|
||||||
|
describe('factory methods', () => {
|
||||||
|
it('creates a following feed', () => {
|
||||||
|
const feed = Feed.following(ownerPubkey)
|
||||||
|
|
||||||
|
expect(feed.owner).toEqual(ownerPubkey)
|
||||||
|
expect(feed.type.value).toBe('following')
|
||||||
|
expect(feed.isSocialFeed).toBe(true)
|
||||||
|
expect(feed.isRelayFeed).toBe(false)
|
||||||
|
expect(feed.relayUrls).toEqual([])
|
||||||
|
expect(feed.lastRefreshedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a pinned feed', () => {
|
||||||
|
const feed = Feed.pinned(ownerPubkey)
|
||||||
|
|
||||||
|
expect(feed.owner).toEqual(ownerPubkey)
|
||||||
|
expect(feed.type.value).toBe('pinned')
|
||||||
|
expect(feed.isSocialFeed).toBe(true)
|
||||||
|
expect(feed.isRelayFeed).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a relay set feed', () => {
|
||||||
|
const relays = [relayUrl1, relayUrl2]
|
||||||
|
const feed = Feed.relays(ownerPubkey, 'my-set', relays)
|
||||||
|
|
||||||
|
expect(feed.owner).toEqual(ownerPubkey)
|
||||||
|
expect(feed.type.value).toBe('relays')
|
||||||
|
expect(feed.type.relaySetId).toBe('my-set')
|
||||||
|
expect(feed.isSocialFeed).toBe(false)
|
||||||
|
expect(feed.isRelayFeed).toBe(true)
|
||||||
|
expect(feed.relayUrls).toHaveLength(2)
|
||||||
|
expect(feed.hasRelayUrls).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a single relay feed', () => {
|
||||||
|
const feed = Feed.singleRelay(relayUrl1)
|
||||||
|
|
||||||
|
expect(feed.owner).toBeNull()
|
||||||
|
expect(feed.type.value).toBe('relay')
|
||||||
|
expect(feed.type.relayUrl).toBe(relayUrl1.value) // Use the actual normalized URL
|
||||||
|
expect(feed.isSocialFeed).toBe(false)
|
||||||
|
expect(feed.isRelayFeed).toBe(true)
|
||||||
|
expect(feed.relayUrls).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates an empty feed', () => {
|
||||||
|
const feed = Feed.empty()
|
||||||
|
|
||||||
|
expect(feed.owner).toBeNull()
|
||||||
|
expect(feed.type.value).toBe('following')
|
||||||
|
expect(feed.relayUrls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('switchTo', () => {
|
||||||
|
it('switches from following to relay feed', () => {
|
||||||
|
const feed = Feed.following(ownerPubkey)
|
||||||
|
const newType = FeedType.relay(relayUrl1.value)
|
||||||
|
|
||||||
|
const event = feed.switchTo(newType, [relayUrl1])
|
||||||
|
|
||||||
|
expect(event).toBeInstanceOf(FeedSwitched)
|
||||||
|
expect(event.fromType?.value).toBe('following')
|
||||||
|
expect(event.toType.value).toBe('relay')
|
||||||
|
expect(feed.type.value).toBe('relay')
|
||||||
|
expect(feed.relayUrls).toHaveLength(1)
|
||||||
|
expect(feed.lastRefreshedAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to relay set feed', () => {
|
||||||
|
const feed = Feed.following(ownerPubkey)
|
||||||
|
const newType = FeedType.relays('my-set')
|
||||||
|
const relays = [relayUrl1, relayUrl2]
|
||||||
|
|
||||||
|
const event = feed.switchTo(newType, relays)
|
||||||
|
|
||||||
|
expect(event.toType.value).toBe('relays')
|
||||||
|
expect(event.relaySetId).toBe('my-set')
|
||||||
|
expect(feed.relayUrls).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to social feed and clears relay URLs', () => {
|
||||||
|
const feed = Feed.singleRelay(relayUrl1)
|
||||||
|
const newType = FeedType.following()
|
||||||
|
|
||||||
|
feed.switchTo(newType)
|
||||||
|
|
||||||
|
expect(feed.type.value).toBe('following')
|
||||||
|
expect(feed.relayUrls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateContentFilter', () => {
|
||||||
|
it('updates content filter and returns event', () => {
|
||||||
|
const feed = Feed.following(ownerPubkey)
|
||||||
|
const newFilter = ContentFilter.default().withHideReplies(true)
|
||||||
|
|
||||||
|
const event = feed.updateContentFilter(newFilter)
|
||||||
|
|
||||||
|
expect(event).toBeInstanceOf(ContentFilterUpdated)
|
||||||
|
expect(feed.contentFilter.hideReplies).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('marks feed as refreshed and returns event', () => {
|
||||||
|
const feed = Feed.following(ownerPubkey)
|
||||||
|
expect(feed.lastRefreshedAt).toBeNull()
|
||||||
|
|
||||||
|
const event = feed.refresh()
|
||||||
|
|
||||||
|
expect(event).toBeInstanceOf(FeedRefreshed)
|
||||||
|
expect(event.feedType.value).toBe('following')
|
||||||
|
expect(feed.lastRefreshedAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildTimelineQuery', () => {
|
||||||
|
it('returns null for social feed without authors', () => {
|
||||||
|
const feed = Feed.following(ownerPubkey)
|
||||||
|
feed.setResolvedRelayUrls([relayUrl1])
|
||||||
|
|
||||||
|
const query = feed.buildTimelineQuery()
|
||||||
|
|
||||||
|
expect(query).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds query for social feed with authors', () => {
|
||||||
|
const feed = Feed.following(ownerPubkey)
|
||||||
|
feed.setResolvedRelayUrls([relayUrl1])
|
||||||
|
const author = Pubkey.fromHex('b'.repeat(64))
|
||||||
|
|
||||||
|
const query = feed.buildTimelineQuery({ authors: [author] })
|
||||||
|
|
||||||
|
expect(query).not.toBeNull()
|
||||||
|
expect(query!.authors).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no relay URLs are resolved', () => {
|
||||||
|
const feed = Feed.following(ownerPubkey)
|
||||||
|
|
||||||
|
const query = feed.buildTimelineQuery({ authors: [ownerPubkey] })
|
||||||
|
|
||||||
|
expect(query).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds query for relay feed', () => {
|
||||||
|
const feed = Feed.singleRelay(relayUrl1)
|
||||||
|
|
||||||
|
const query = feed.buildTimelineQuery()
|
||||||
|
|
||||||
|
expect(query).not.toBeNull()
|
||||||
|
expect(query!.relays).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toState/fromState', () => {
|
||||||
|
it('serializes and deserializes following feed', () => {
|
||||||
|
const feed = Feed.following(ownerPubkey)
|
||||||
|
feed.setResolvedRelayUrls([relayUrl1])
|
||||||
|
feed.refresh()
|
||||||
|
|
||||||
|
const state = feed.toState()
|
||||||
|
const restored = Feed.fromState(state, ownerPubkey)
|
||||||
|
|
||||||
|
expect(restored.type.value).toBe('following')
|
||||||
|
expect(restored.relayUrls).toHaveLength(1)
|
||||||
|
expect(restored.lastRefreshedAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes and deserializes relay set feed', () => {
|
||||||
|
const feed = Feed.relays(ownerPubkey, 'test-set', [relayUrl1, relayUrl2])
|
||||||
|
|
||||||
|
const state = feed.toState()
|
||||||
|
const restored = Feed.fromState(state, ownerPubkey)
|
||||||
|
|
||||||
|
expect(restored.type.value).toBe('relays')
|
||||||
|
expect(restored.type.relaySetId).toBe('test-set')
|
||||||
|
expect(restored.relayUrls).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles invalid state gracefully', () => {
|
||||||
|
const invalidState = {
|
||||||
|
feedType: 'invalid',
|
||||||
|
relayUrls: [],
|
||||||
|
contentFilter: {
|
||||||
|
hideMutedUsers: true,
|
||||||
|
hideContentMentioningMuted: false,
|
||||||
|
hideUntrustedUsers: false,
|
||||||
|
hideReplies: false,
|
||||||
|
hideReposts: false,
|
||||||
|
allowedKinds: [],
|
||||||
|
nsfwPolicy: 'hide'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restored = Feed.fromState(invalidState)
|
||||||
|
|
||||||
|
expect(restored.type.value).toBe('following') // Falls back to empty/following
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('withOwner', () => {
|
||||||
|
it('creates a copy with new owner', () => {
|
||||||
|
const feed = Feed.singleRelay(relayUrl1)
|
||||||
|
const newOwner = Pubkey.fromHex('c'.repeat(64))
|
||||||
|
|
||||||
|
const feedWithOwner = feed.withOwner(newOwner)
|
||||||
|
|
||||||
|
expect(feedWithOwner.owner).toEqual(newOwner)
|
||||||
|
expect(feedWithOwner.type.value).toBe('relay')
|
||||||
|
expect(feedWithOwner.relayUrls).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('returns true for identical feeds', () => {
|
||||||
|
const feed1 = Feed.following(ownerPubkey)
|
||||||
|
const feed2 = Feed.following(ownerPubkey)
|
||||||
|
|
||||||
|
expect(feed1.equals(feed2)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for different feed types', () => {
|
||||||
|
const feed1 = Feed.following(ownerPubkey)
|
||||||
|
const feed2 = Feed.pinned(ownerPubkey)
|
||||||
|
|
||||||
|
expect(feed1.equals(feed2)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for different relay URLs', () => {
|
||||||
|
const feed1 = Feed.relays(ownerPubkey, 'set', [relayUrl1])
|
||||||
|
const feed2 = Feed.relays(ownerPubkey, 'set', [relayUrl1, relayUrl2])
|
||||||
|
|
||||||
|
expect(feed1.equals(feed2)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
411
src/domain/feed/Feed.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import { Pubkey } from '../shared/value-objects/Pubkey'
|
||||||
|
import { RelayUrl } from '../shared/value-objects/RelayUrl'
|
||||||
|
import { Timestamp } from '../shared/value-objects/Timestamp'
|
||||||
|
import { FeedType } from './FeedType'
|
||||||
|
import { ContentFilter } from './ContentFilter'
|
||||||
|
import { RelayStrategy } from './RelayStrategy'
|
||||||
|
import { TimelineQuery } from './TimelineQuery'
|
||||||
|
import { FeedSwitched, ContentFilterUpdated, FeedRefreshed } from './events'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for switching feeds
|
||||||
|
*/
|
||||||
|
export interface FeedSwitchOptions {
|
||||||
|
relaySetId?: string
|
||||||
|
relayUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for building timeline queries
|
||||||
|
*/
|
||||||
|
export interface TimelineQueryOptions {
|
||||||
|
authors?: Pubkey[]
|
||||||
|
kinds?: number[]
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable state for persistence
|
||||||
|
*/
|
||||||
|
export interface FeedState {
|
||||||
|
feedType: string
|
||||||
|
relaySetId?: string
|
||||||
|
relayUrl?: string
|
||||||
|
relayUrls: string[]
|
||||||
|
contentFilter: {
|
||||||
|
hideMutedUsers: boolean
|
||||||
|
hideContentMentioningMuted: boolean
|
||||||
|
hideUntrustedUsers: boolean
|
||||||
|
hideReplies: boolean
|
||||||
|
hideReposts: boolean
|
||||||
|
allowedKinds: number[]
|
||||||
|
nsfwPolicy: string
|
||||||
|
}
|
||||||
|
lastRefreshedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed Aggregate
|
||||||
|
*
|
||||||
|
* Represents the user's active feed configuration and state.
|
||||||
|
* This is the aggregate root for the Feed bounded context's query side.
|
||||||
|
*
|
||||||
|
* Invariants:
|
||||||
|
* - Must have a valid feed type
|
||||||
|
* - For relay feeds, must have resolved relay URLs
|
||||||
|
* - Content filter is always present with sensible defaults
|
||||||
|
*/
|
||||||
|
export class Feed {
|
||||||
|
private constructor(
|
||||||
|
private readonly _owner: Pubkey | null,
|
||||||
|
private _feedType: FeedType,
|
||||||
|
private _relayStrategy: RelayStrategy,
|
||||||
|
private _resolvedRelayUrls: RelayUrl[],
|
||||||
|
private _contentFilter: ContentFilter,
|
||||||
|
private _lastRefreshedAt: Timestamp | null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a following feed (shows posts from followed users)
|
||||||
|
*/
|
||||||
|
static following(owner: Pubkey): Feed {
|
||||||
|
return new Feed(
|
||||||
|
owner,
|
||||||
|
FeedType.following(),
|
||||||
|
RelayStrategy.authorWriteRelays(),
|
||||||
|
[],
|
||||||
|
ContentFilter.default(),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a pinned users feed
|
||||||
|
*/
|
||||||
|
static pinned(owner: Pubkey): Feed {
|
||||||
|
return new Feed(
|
||||||
|
owner,
|
||||||
|
FeedType.pinned(),
|
||||||
|
RelayStrategy.authorWriteRelays(),
|
||||||
|
[],
|
||||||
|
ContentFilter.default(),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a relay set feed
|
||||||
|
*/
|
||||||
|
static relays(owner: Pubkey, setId: string, relayUrls: RelayUrl[]): Feed {
|
||||||
|
return new Feed(
|
||||||
|
owner,
|
||||||
|
FeedType.relays(setId),
|
||||||
|
RelayStrategy.specific(relayUrls, setId),
|
||||||
|
relayUrls,
|
||||||
|
ContentFilter.default(),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a single relay feed
|
||||||
|
*/
|
||||||
|
static singleRelay(relayUrl: RelayUrl): Feed {
|
||||||
|
return new Feed(
|
||||||
|
null,
|
||||||
|
FeedType.relay(relayUrl.value),
|
||||||
|
RelayStrategy.single(relayUrl),
|
||||||
|
[relayUrl],
|
||||||
|
ContentFilter.default(),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an empty/uninitialized feed
|
||||||
|
*/
|
||||||
|
static empty(): Feed {
|
||||||
|
return new Feed(
|
||||||
|
null,
|
||||||
|
FeedType.following(),
|
||||||
|
RelayStrategy.bigRelays(),
|
||||||
|
[],
|
||||||
|
ContentFilter.default(),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore from persisted state
|
||||||
|
*/
|
||||||
|
static fromState(state: FeedState, owner?: Pubkey): Feed {
|
||||||
|
const feedType = FeedType.tryFromString(
|
||||||
|
state.feedType,
|
||||||
|
state.relaySetId ?? state.relayUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!feedType) {
|
||||||
|
return Feed.empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayUrls = state.relayUrls
|
||||||
|
.map((url) => RelayUrl.tryCreate(url))
|
||||||
|
.filter((r): r is RelayUrl => r !== null)
|
||||||
|
|
||||||
|
let relayStrategy: RelayStrategy
|
||||||
|
if (feedType.value === 'relay' && relayUrls.length > 0) {
|
||||||
|
relayStrategy = RelayStrategy.single(relayUrls[0])
|
||||||
|
} else if (feedType.value === 'relays' && relayUrls.length > 0) {
|
||||||
|
relayStrategy = RelayStrategy.specific(relayUrls, state.relaySetId)
|
||||||
|
} else if (feedType.isSocialFeed) {
|
||||||
|
relayStrategy = RelayStrategy.authorWriteRelays()
|
||||||
|
} else {
|
||||||
|
relayStrategy = RelayStrategy.bigRelays()
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentFilter = ContentFilter.fromPreferences({
|
||||||
|
hideMutedUsers: state.contentFilter.hideMutedUsers,
|
||||||
|
hideContentMentioningMuted: state.contentFilter.hideContentMentioningMuted,
|
||||||
|
hideUntrustedUsers: state.contentFilter.hideUntrustedUsers,
|
||||||
|
hideReplies: state.contentFilter.hideReplies,
|
||||||
|
hideReposts: state.contentFilter.hideReposts,
|
||||||
|
allowedKinds: state.contentFilter.allowedKinds,
|
||||||
|
nsfwPolicy: state.contentFilter.nsfwPolicy as 'hide' | 'hide_content' | 'show'
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Feed(
|
||||||
|
owner ?? null,
|
||||||
|
feedType,
|
||||||
|
relayStrategy,
|
||||||
|
relayUrls,
|
||||||
|
contentFilter,
|
||||||
|
state.lastRefreshedAt ? Timestamp.fromUnix(state.lastRefreshedAt) : null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Queries
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
get owner(): Pubkey | null {
|
||||||
|
return this._owner
|
||||||
|
}
|
||||||
|
|
||||||
|
get type(): FeedType {
|
||||||
|
return this._feedType
|
||||||
|
}
|
||||||
|
|
||||||
|
get relayStrategy(): RelayStrategy {
|
||||||
|
return this._relayStrategy
|
||||||
|
}
|
||||||
|
|
||||||
|
get relayUrls(): readonly RelayUrl[] {
|
||||||
|
return this._resolvedRelayUrls
|
||||||
|
}
|
||||||
|
|
||||||
|
get contentFilter(): ContentFilter {
|
||||||
|
return this._contentFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastRefreshedAt(): Timestamp | null {
|
||||||
|
return this._lastRefreshedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a social feed (following or pinned)
|
||||||
|
*/
|
||||||
|
get isSocialFeed(): boolean {
|
||||||
|
return this._feedType.isSocialFeed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a relay-based feed
|
||||||
|
*/
|
||||||
|
get isRelayFeed(): boolean {
|
||||||
|
return this._feedType.isRelayFeed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the feed has resolved relay URLs
|
||||||
|
*/
|
||||||
|
get hasRelayUrls(): boolean {
|
||||||
|
return this._resolvedRelayUrls.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relay URLs as strings for compatibility
|
||||||
|
*/
|
||||||
|
get relayUrlStrings(): string[] {
|
||||||
|
return this._resolvedRelayUrls.map((r) => r.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Commands
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different feed type
|
||||||
|
* Returns a domain event describing the change
|
||||||
|
*/
|
||||||
|
switchTo(newType: FeedType, relayUrls: RelayUrl[] = []): FeedSwitched {
|
||||||
|
const previousType = this._feedType
|
||||||
|
|
||||||
|
this._feedType = newType
|
||||||
|
|
||||||
|
// Update relay strategy based on new type
|
||||||
|
if (newType.value === 'relay' && relayUrls.length > 0) {
|
||||||
|
this._relayStrategy = RelayStrategy.single(relayUrls[0])
|
||||||
|
this._resolvedRelayUrls = [relayUrls[0]]
|
||||||
|
} else if (newType.value === 'relays' && relayUrls.length > 0) {
|
||||||
|
this._relayStrategy = RelayStrategy.specific(relayUrls, newType.relaySetId ?? undefined)
|
||||||
|
this._resolvedRelayUrls = relayUrls
|
||||||
|
} else if (newType.isSocialFeed) {
|
||||||
|
this._relayStrategy = RelayStrategy.authorWriteRelays()
|
||||||
|
this._resolvedRelayUrls = []
|
||||||
|
} else {
|
||||||
|
this._relayStrategy = RelayStrategy.bigRelays()
|
||||||
|
this._resolvedRelayUrls = []
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastRefreshedAt = Timestamp.now()
|
||||||
|
|
||||||
|
return new FeedSwitched(
|
||||||
|
this._owner,
|
||||||
|
previousType,
|
||||||
|
newType,
|
||||||
|
newType.relaySetId ?? undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the resolved relay URLs (after resolution)
|
||||||
|
*/
|
||||||
|
setResolvedRelayUrls(urls: RelayUrl[]): void {
|
||||||
|
this._resolvedRelayUrls = [...urls]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update content filter settings
|
||||||
|
* Returns a domain event describing the change
|
||||||
|
*/
|
||||||
|
updateContentFilter(newFilter: ContentFilter): ContentFilterUpdated {
|
||||||
|
const previousFilter = this._contentFilter
|
||||||
|
this._contentFilter = newFilter
|
||||||
|
|
||||||
|
return new ContentFilterUpdated(
|
||||||
|
this._owner!,
|
||||||
|
previousFilter,
|
||||||
|
newFilter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the feed as refreshed
|
||||||
|
* Returns a domain event
|
||||||
|
*/
|
||||||
|
refresh(): FeedRefreshed {
|
||||||
|
this._lastRefreshedAt = Timestamp.now()
|
||||||
|
|
||||||
|
return new FeedRefreshed(this._owner, this._feedType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timeline Query Building
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a timeline query for this feed configuration
|
||||||
|
*
|
||||||
|
* For social feeds, authors should be provided (followings or pinned users).
|
||||||
|
* For relay feeds, the resolved relay URLs are used.
|
||||||
|
*/
|
||||||
|
buildTimelineQuery(options: TimelineQueryOptions = {}): TimelineQuery | null {
|
||||||
|
// Need relay URLs to build a query
|
||||||
|
if (this._resolvedRelayUrls.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSocialFeed) {
|
||||||
|
// Social feeds need authors
|
||||||
|
if (!options.authors || options.authors.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineQuery.forAuthors(
|
||||||
|
options.authors,
|
||||||
|
this._resolvedRelayUrls,
|
||||||
|
{
|
||||||
|
kinds: options.kinds,
|
||||||
|
limit: options.limit
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay feeds - global query
|
||||||
|
return TimelineQuery.forRelay(
|
||||||
|
this._resolvedRelayUrls[0],
|
||||||
|
{
|
||||||
|
kinds: options.kinds,
|
||||||
|
limit: options.limit
|
||||||
|
}
|
||||||
|
).withRelays(this._resolvedRelayUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Persistence
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to serializable state for persistence
|
||||||
|
*/
|
||||||
|
toState(): FeedState {
|
||||||
|
return {
|
||||||
|
feedType: this._feedType.value,
|
||||||
|
relaySetId: this._feedType.relaySetId ?? undefined,
|
||||||
|
relayUrl: this._feedType.relayUrl ?? undefined,
|
||||||
|
relayUrls: this._resolvedRelayUrls.map((r) => r.value),
|
||||||
|
contentFilter: {
|
||||||
|
hideMutedUsers: this._contentFilter.hideMutedUsers,
|
||||||
|
hideContentMentioningMuted: this._contentFilter.hideContentMentioningMuted,
|
||||||
|
hideUntrustedUsers: this._contentFilter.hideUntrustedUsers,
|
||||||
|
hideReplies: this._contentFilter.hideReplies,
|
||||||
|
hideReposts: this._contentFilter.hideReposts,
|
||||||
|
allowedKinds: [...this._contentFilter.allowedKinds],
|
||||||
|
nsfwPolicy: this._contentFilter.nsfwPolicy
|
||||||
|
},
|
||||||
|
lastRefreshedAt: this._lastRefreshedAt?.unix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a copy of this feed with a new owner
|
||||||
|
*/
|
||||||
|
withOwner(owner: Pubkey): Feed {
|
||||||
|
return new Feed(
|
||||||
|
owner,
|
||||||
|
this._feedType,
|
||||||
|
this._relayStrategy,
|
||||||
|
[...this._resolvedRelayUrls],
|
||||||
|
this._contentFilter,
|
||||||
|
this._lastRefreshedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check equality with another feed
|
||||||
|
*/
|
||||||
|
equals(other: Feed): boolean {
|
||||||
|
if (!this._feedType.equals(other._feedType)) return false
|
||||||
|
if (this._resolvedRelayUrls.length !== other._resolvedRelayUrls.length) return false
|
||||||
|
|
||||||
|
for (let i = 0; i < this._resolvedRelayUrls.length; i++) {
|
||||||
|
if (!this._resolvedRelayUrls[i].equals(other._resolvedRelayUrls[i])) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._contentFilter.equals(other._contentFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
282
src/domain/feed/FeedFilter.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { ContentFilter, FilterContext, FilterResult, FilterReason } from './ContentFilter'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for checking if a pubkey is muted
|
||||||
|
*/
|
||||||
|
export interface MuteChecker {
|
||||||
|
isMuted(pubkey: string): boolean
|
||||||
|
getMutedPubkeys(): Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for checking if a pubkey is trusted
|
||||||
|
*/
|
||||||
|
export interface TrustChecker {
|
||||||
|
isTrusted(pubkey: string): boolean
|
||||||
|
getTrustedPubkeys(): Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for checking if an event is deleted
|
||||||
|
*/
|
||||||
|
export interface DeletionChecker {
|
||||||
|
isDeleted(eventId: string): boolean
|
||||||
|
getDeletedEventIds(): Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for checking if an event is pinned
|
||||||
|
*/
|
||||||
|
export interface PinnedChecker {
|
||||||
|
isPinned(eventId: string): boolean
|
||||||
|
getPinnedEventIds(): Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of filtering with the original event
|
||||||
|
*/
|
||||||
|
export interface FilteredEvent {
|
||||||
|
event: Event
|
||||||
|
result: FilterResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistics about filtering results
|
||||||
|
*/
|
||||||
|
export interface FilterStats {
|
||||||
|
total: number
|
||||||
|
shown: number
|
||||||
|
hidden: number
|
||||||
|
byReason: Map<FilterReason, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeedFilter Domain Service
|
||||||
|
*
|
||||||
|
* Coordinates filtering of timeline events using ContentFilter and various
|
||||||
|
* checkers (mute, trust, deletion). This is a domain service because it
|
||||||
|
* requires coordination between multiple domain concepts.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - Inject checkers that provide mute/trust/deletion data
|
||||||
|
* - Call filterEvents() to filter a batch of events
|
||||||
|
* - Call shouldDisplay() to check a single event
|
||||||
|
*/
|
||||||
|
export class FeedFilter {
|
||||||
|
constructor(
|
||||||
|
private readonly muteChecker: MuteChecker,
|
||||||
|
private readonly trustChecker?: TrustChecker,
|
||||||
|
private readonly deletionChecker?: DeletionChecker,
|
||||||
|
private readonly pinnedChecker?: PinnedChecker,
|
||||||
|
private readonly currentUserPubkey?: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a filter context from the current checker state
|
||||||
|
*/
|
||||||
|
private buildContext(): FilterContext {
|
||||||
|
return {
|
||||||
|
mutedPubkeys: this.muteChecker.getMutedPubkeys(),
|
||||||
|
trustedPubkeys: this.trustChecker?.getTrustedPubkeys(),
|
||||||
|
deletedEventIds: this.deletionChecker?.getDeletedEventIds(),
|
||||||
|
pinnedEventIds: this.pinnedChecker?.getPinnedEventIds(),
|
||||||
|
currentUserPubkey: this.currentUserPubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter a batch of events, returning only those that should be shown
|
||||||
|
*/
|
||||||
|
filterEvents(events: Event[], filter: ContentFilter): Event[] {
|
||||||
|
const context = this.buildContext()
|
||||||
|
return events.filter((event) => filter.shouldShow(event, context).shouldShow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter events and return both shown and hidden with reasons
|
||||||
|
*/
|
||||||
|
filterEventsWithDetails(events: Event[], filter: ContentFilter): FilteredEvent[] {
|
||||||
|
const context = this.buildContext()
|
||||||
|
return events.map((event) => ({
|
||||||
|
event,
|
||||||
|
result: filter.shouldShow(event, context)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only events that should be shown with their filter results
|
||||||
|
*/
|
||||||
|
getShownEvents(events: Event[], filter: ContentFilter): FilteredEvent[] {
|
||||||
|
return this.filterEventsWithDetails(events, filter).filter((fe) => fe.result.shouldShow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only events that were hidden with their reasons
|
||||||
|
*/
|
||||||
|
getHiddenEvents(events: Event[], filter: ContentFilter): FilteredEvent[] {
|
||||||
|
return this.filterEventsWithDetails(events, filter).filter((fe) => !fe.result.shouldShow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a single event should be displayed
|
||||||
|
*/
|
||||||
|
shouldDisplay(event: Event, filter: ContentFilter): FilterResult {
|
||||||
|
const context = this.buildContext()
|
||||||
|
return filter.shouldShow(event, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics about filtering a batch of events
|
||||||
|
*/
|
||||||
|
getFilterStats(events: Event[], filter: ContentFilter): FilterStats {
|
||||||
|
const results = this.filterEventsWithDetails(events, filter)
|
||||||
|
const byReason = new Map<FilterReason, number>()
|
||||||
|
|
||||||
|
let shown = 0
|
||||||
|
let hidden = 0
|
||||||
|
|
||||||
|
for (const { result } of results) {
|
||||||
|
if (result.shouldShow) {
|
||||||
|
shown++
|
||||||
|
} else {
|
||||||
|
hidden++
|
||||||
|
if (result.reason) {
|
||||||
|
byReason.set(result.reason, (byReason.get(result.reason) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: events.length,
|
||||||
|
shown,
|
||||||
|
hidden,
|
||||||
|
byReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new FeedFilter with an updated mute checker
|
||||||
|
*/
|
||||||
|
withMuteChecker(muteChecker: MuteChecker): FeedFilter {
|
||||||
|
return new FeedFilter(
|
||||||
|
muteChecker,
|
||||||
|
this.trustChecker,
|
||||||
|
this.deletionChecker,
|
||||||
|
this.pinnedChecker,
|
||||||
|
this.currentUserPubkey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new FeedFilter with an updated trust checker
|
||||||
|
*/
|
||||||
|
withTrustChecker(trustChecker: TrustChecker): FeedFilter {
|
||||||
|
return new FeedFilter(
|
||||||
|
this.muteChecker,
|
||||||
|
trustChecker,
|
||||||
|
this.deletionChecker,
|
||||||
|
this.pinnedChecker,
|
||||||
|
this.currentUserPubkey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new FeedFilter with an updated deletion checker
|
||||||
|
*/
|
||||||
|
withDeletionChecker(deletionChecker: DeletionChecker): FeedFilter {
|
||||||
|
return new FeedFilter(
|
||||||
|
this.muteChecker,
|
||||||
|
this.trustChecker,
|
||||||
|
deletionChecker,
|
||||||
|
this.pinnedChecker,
|
||||||
|
this.currentUserPubkey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new FeedFilter with an updated pinned checker
|
||||||
|
*/
|
||||||
|
withPinnedChecker(pinnedChecker: PinnedChecker): FeedFilter {
|
||||||
|
return new FeedFilter(
|
||||||
|
this.muteChecker,
|
||||||
|
this.trustChecker,
|
||||||
|
this.deletionChecker,
|
||||||
|
pinnedChecker,
|
||||||
|
this.currentUserPubkey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new FeedFilter with an updated current user
|
||||||
|
*/
|
||||||
|
withCurrentUser(pubkey: string): FeedFilter {
|
||||||
|
return new FeedFilter(
|
||||||
|
this.muteChecker,
|
||||||
|
this.trustChecker,
|
||||||
|
this.deletionChecker,
|
||||||
|
this.pinnedChecker,
|
||||||
|
pubkey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory implementation of MuteChecker for testing
|
||||||
|
*/
|
||||||
|
export class SimpleMuteChecker implements MuteChecker {
|
||||||
|
constructor(private readonly mutedPubkeys: Set<string> = new Set()) {}
|
||||||
|
|
||||||
|
isMuted(pubkey: string): boolean {
|
||||||
|
return this.mutedPubkeys.has(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMutedPubkeys(): Set<string> {
|
||||||
|
return this.mutedPubkeys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory implementation of TrustChecker for testing
|
||||||
|
*/
|
||||||
|
export class SimpleTrustChecker implements TrustChecker {
|
||||||
|
constructor(private readonly trustedPubkeys: Set<string> = new Set()) {}
|
||||||
|
|
||||||
|
isTrusted(pubkey: string): boolean {
|
||||||
|
return this.trustedPubkeys.has(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrustedPubkeys(): Set<string> {
|
||||||
|
return this.trustedPubkeys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory implementation of DeletionChecker for testing
|
||||||
|
*/
|
||||||
|
export class SimpleDeletionChecker implements DeletionChecker {
|
||||||
|
constructor(private readonly deletedEventIds: Set<string> = new Set()) {}
|
||||||
|
|
||||||
|
isDeleted(eventId: string): boolean {
|
||||||
|
return this.deletedEventIds.has(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeletedEventIds(): Set<string> {
|
||||||
|
return this.deletedEventIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory implementation of PinnedChecker for testing
|
||||||
|
*/
|
||||||
|
export class SimplePinnedChecker implements PinnedChecker {
|
||||||
|
constructor(private readonly pinnedEventIds: Set<string> = new Set()) {}
|
||||||
|
|
||||||
|
isPinned(eventId: string): boolean {
|
||||||
|
return this.pinnedEventIds.has(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPinnedEventIds(): Set<string> {
|
||||||
|
return this.pinnedEventIds
|
||||||
|
}
|
||||||
|
}
|
||||||