Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d58162890 | ||
|
|
9820a1c6c0 | ||
|
|
ad5f9cccf9 | ||
|
|
2e3b854037 | ||
|
|
fecd4fdd45 | ||
|
|
f78138c7c4 | ||
|
|
cdfd034c68 |
31
.claude/commands/deploy.md
Normal file
31
.claude/commands/deploy.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Deploy Command
|
||||||
|
|
||||||
|
Deploy smesh to the VPS at mleku.dev, serving on port 3008 behind smesh.mleku.dev.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. Build the project locally:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. If build fails, fix any errors and retry before proceeding.
|
||||||
|
|
||||||
|
3. Sync the dist folder to the VPS:
|
||||||
|
```bash
|
||||||
|
rsync -avz --delete dist/ mleku.dev:~/smesh/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart the smesh service on the VPS:
|
||||||
|
```bash
|
||||||
|
ssh mleku.dev "sudo systemctl restart smesh"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Verify the service is running:
|
||||||
|
```bash
|
||||||
|
ssh mleku.dev "sudo systemctl status smesh"
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Report the deployment status and the URL: https://smesh.mleku.dev
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
315
package-lock.json
generated
315
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.1.0",
|
"version": "0.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.1.0",
|
"version": "0.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"@tiptap/react": "^2.12.0",
|
"@tiptap/react": "^2.12.0",
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"@tiptap/suggestion": "^2.12.0",
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@webbtc/webln-types": "^3.0.0",
|
"@webbtc/webln-types": "^3.0.0",
|
||||||
"blossom-client-sdk": "^4.1.0",
|
"blossom-client-sdk": "^4.1.0",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"path-to-regexp": "^8.2.0",
|
"path-to-regexp": "^8.2.0",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.2.0",
|
"react-i18next": "^15.2.0",
|
||||||
@@ -5875,7 +5877,6 @@
|
|||||||
"version": "22.10.2",
|
"version": "22.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
|
||||||
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
|
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.20.0"
|
"undici-types": "~6.20.0"
|
||||||
}
|
}
|
||||||
@@ -5891,6 +5892,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
||||||
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="
|
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.18",
|
"version": "18.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
|
||||||
@@ -6630,6 +6640,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/camelcase-css": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@@ -6764,6 +6783,72 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"url": "https://polar.sh/cva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -7386,6 +7471,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decode-named-character-reference": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||||
@@ -7477,6 +7571,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
|
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dlv": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
@@ -8282,6 +8382,15 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
|
||||||
@@ -10615,6 +10724,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
@@ -10660,7 +10778,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -10747,6 +10864,15 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||||
@@ -11152,6 +11278,23 @@
|
|||||||
"@types/offscreencanvas": "^2019.6.4"
|
"@types/offscreencanvas": "^2019.6.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qrcode-generator": {
|
"node_modules/qrcode-generator": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
|
||||||
@@ -11534,6 +11677,15 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -11543,6 +11695,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
@@ -11732,6 +11890,12 @@
|
|||||||
"randombytes": "^2.1.0"
|
"randombytes": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -12635,8 +12799,7 @@
|
|||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.20.0",
|
"version": "6.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@@ -13181,6 +13344,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz",
|
||||||
@@ -13640,6 +13809,12 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@@ -13657,6 +13832,134 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yet-another-react-lightbox": {
|
"node_modules/yet-another-react-lightbox": {
|
||||||
"version": "3.21.7",
|
"version": "3.21.7",
|
||||||
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.21.7.tgz",
|
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.21.7.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.2.1",
|
"version": "0.2.5",
|
||||||
"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",
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
"@tiptap/react": "^2.12.0",
|
"@tiptap/react": "^2.12.0",
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"@tiptap/suggestion": "^2.12.0",
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@webbtc/webln-types": "^3.0.0",
|
"@webbtc/webln-types": "^3.0.0",
|
||||||
"blossom-client-sdk": "^4.1.0",
|
"blossom-client-sdk": "^4.1.0",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
"path-to-regexp": "^8.2.0",
|
"path-to-regexp": "^8.2.0",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.2.0",
|
"react-i18next": "^15.2.0",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { DeletedEventProvider } from '@/providers/DeletedEventProvider'
|
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
|
||||||
|
import { DMProvider } from '@/providers/DMProvider'
|
||||||
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
|
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
|
||||||
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
|
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
|
||||||
import { FeedProvider } from '@/providers/FeedProvider'
|
import { FeedProvider } from '@/providers/FeedProvider'
|
||||||
@@ -38,8 +39,9 @@ export default function App(): JSX.Element {
|
|||||||
<FavoriteRelaysProvider>
|
<FavoriteRelaysProvider>
|
||||||
<FollowListProvider>
|
<FollowListProvider>
|
||||||
<MuteListProvider>
|
<MuteListProvider>
|
||||||
<UserTrustProvider>
|
<DMProvider>
|
||||||
<BookmarksProvider>
|
<UserTrustProvider>
|
||||||
|
<BookmarksProvider>
|
||||||
<EmojiPackProvider>
|
<EmojiPackProvider>
|
||||||
<PinListProvider>
|
<PinListProvider>
|
||||||
<PinnedUsersProvider>
|
<PinnedUsersProvider>
|
||||||
@@ -55,7 +57,8 @@ export default function App(): JSX.Element {
|
|||||||
</PinListProvider>
|
</PinListProvider>
|
||||||
</EmojiPackProvider>
|
</EmojiPackProvider>
|
||||||
</BookmarksProvider>
|
</BookmarksProvider>
|
||||||
</UserTrustProvider>
|
</UserTrustProvider>
|
||||||
|
</DMProvider>
|
||||||
</MuteListProvider>
|
</MuteListProvider>
|
||||||
</FollowListProvider>
|
</FollowListProvider>
|
||||||
</FavoriteRelaysProvider>
|
</FavoriteRelaysProvider>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function BunkerLogin({
|
|||||||
onLoginSuccess: () => void
|
onLoginSuccess: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { bunkerLoginWithSigner } = useNostr()
|
const { bunkerLoginWithSigner, bunkerLogin } = useNostr()
|
||||||
const [mode, setMode] = useState<'choose' | 'scan' | 'paste'>('choose')
|
const [mode, setMode] = useState<'choose' | 'scan' | 'paste'>('choose')
|
||||||
const [bunkerUrl, setBunkerUrl] = useState('')
|
const [bunkerUrl, setBunkerUrl] = useState('')
|
||||||
const [relayUrl, setRelayUrl] = useState(DEFAULT_BUNKER_RELAY)
|
const [relayUrl, setRelayUrl] = useState(DEFAULT_BUNKER_RELAY)
|
||||||
@@ -105,7 +105,6 @@ export default function BunkerLogin({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the existing bunkerLogin flow for bunker:// URLs
|
// Use the existing bunkerLogin flow for bunker:// URLs
|
||||||
const { bunkerLogin } = useNostr()
|
|
||||||
await bunkerLogin(bunkerUrl.trim())
|
await bunkerLogin(bunkerUrl.trim())
|
||||||
onLoginSuccess()
|
onLoginSuccess()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
97
src/components/Inbox/ConversationItem.tsx
Normal file
97
src/components/Inbox/ConversationItem.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
|
import { formatTimestamp } from '@/lib/timestamp'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import { TConversation, TProfile } from '@/types'
|
||||||
|
import { Lock, Users, X } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface ConversationItemProps {
|
||||||
|
conversation: TConversation
|
||||||
|
isActive: boolean
|
||||||
|
isFollowing: boolean
|
||||||
|
onClick: () => void
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversationItem({
|
||||||
|
conversation,
|
||||||
|
isActive,
|
||||||
|
isFollowing,
|
||||||
|
onClick,
|
||||||
|
onClose
|
||||||
|
}: ConversationItemProps) {
|
||||||
|
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfileData = async () => {
|
||||||
|
try {
|
||||||
|
const profileData = await client.fetchProfile(conversation.partnerPubkey)
|
||||||
|
if (profileData) {
|
||||||
|
setProfile(profileData)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profile:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProfileData()
|
||||||
|
}, [conversation.partnerPubkey])
|
||||||
|
|
||||||
|
const displayName = profile?.username || conversation.partnerPubkey.slice(0, 8) + '...'
|
||||||
|
const formattedTime = formatTimestamp(conversation.lastMessageAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-start gap-3 p-3 hover:bg-accent/50 transition-colors text-left',
|
||||||
|
isActive && 'bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserAvatar userId={conversation.partnerPubkey} className="size-10 flex-shrink-0" />
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="font-medium text-sm truncate">{displayName}</span>
|
||||||
|
{isFollowing && (
|
||||||
|
<span className="text-xs text-primary flex-shrink-0" title="Following">
|
||||||
|
<Users className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground">{formattedTime}</span>
|
||||||
|
{isActive && onClose && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded hover:bg-muted-foreground/20 transition-colors"
|
||||||
|
title="Close conversation"
|
||||||
|
>
|
||||||
|
<X className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
{conversation.preferredEncryption === 'nip17' && (
|
||||||
|
<span title="NIP-17 encrypted">
|
||||||
|
<Lock className="size-3 text-green-500 flex-shrink-0" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{conversation.lastMessagePreview}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conversation.unreadCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center size-5 text-xs rounded-full bg-primary text-primary-foreground mt-1">
|
||||||
|
{conversation.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
158
src/components/Inbox/ConversationList.tsx
Normal file
158
src/components/Inbox/ConversationList.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { toDMConversation } from '@/lib/link'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
|
import storage from '@/services/local-storage.service'
|
||||||
|
import { Check, Loader2, MessageSquare, MoreVertical, RefreshCw } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '../ui/dropdown-menu'
|
||||||
|
import { ScrollArea } from '../ui/scroll-area'
|
||||||
|
import ConversationItem from './ConversationItem'
|
||||||
|
|
||||||
|
export default function ConversationList() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { push, pop } = useSecondaryPage()
|
||||||
|
const {
|
||||||
|
conversations,
|
||||||
|
currentConversation,
|
||||||
|
selectConversation,
|
||||||
|
refreshConversations,
|
||||||
|
loadMoreConversations,
|
||||||
|
hasMoreConversations,
|
||||||
|
isLoading
|
||||||
|
} = useDM()
|
||||||
|
const { followingSet } = useFollowList()
|
||||||
|
const { mutePubkeySet } = useMuteList()
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [filterMode, setFilterMode] = useState<'all' | 'follows'>(() =>
|
||||||
|
storage.getDMConversationFilter()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter and sort conversations
|
||||||
|
const sortedConversations = useMemo(() => {
|
||||||
|
let filtered = [...conversations]
|
||||||
|
|
||||||
|
if (filterMode === 'follows') {
|
||||||
|
// Only show conversations from follows, and hide muted users
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(c) => followingSet.has(c.partnerPubkey) && !mutePubkeySet.has(c.partnerPubkey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => b.lastMessageAt - a.lastMessageAt)
|
||||||
|
}, [conversations, filterMode, followingSet, mutePubkeySet])
|
||||||
|
|
||||||
|
const handleFilterChange = (mode: 'all' | 'follows') => {
|
||||||
|
setFilterMode(mode)
|
||||||
|
storage.setDMConversationFilter(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infinite scroll: load more when sentinel is visible
|
||||||
|
const handleIntersection = useCallback(
|
||||||
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
|
const [entry] = entries
|
||||||
|
if (entry.isIntersecting && hasMoreConversations && !isLoading) {
|
||||||
|
loadMoreConversations()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasMoreConversations, isLoading, loadMoreConversations]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(handleIntersection, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '100px',
|
||||||
|
threshold: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (loadMoreRef.current) {
|
||||||
|
observer.observe(loadMoreRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [handleIntersection])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b">
|
||||||
|
<span className="font-medium text-sm">{t('Conversations')}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
onClick={refreshConversations}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleFilterChange('follows')}>
|
||||||
|
{filterMode === 'follows' && <Check className="size-4 mr-2" />}
|
||||||
|
<span className={filterMode !== 'follows' ? 'ml-6' : ''}>
|
||||||
|
{t('Only show follows')}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleFilterChange('all')}>
|
||||||
|
{filterMode === 'all' && <Check className="size-4 mr-2" />}
|
||||||
|
<span className={filterMode !== 'all' ? 'ml-6' : ''}>{t('Show all')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{sortedConversations.length === 0 && !isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground px-4">
|
||||||
|
<MessageSquare className="size-8" />
|
||||||
|
<p className="text-sm text-center">{t('No conversations yet')}</p>
|
||||||
|
<p className="text-xs text-center">{t('Start a conversation by visiting a profile')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{sortedConversations.map((conversation) => (
|
||||||
|
<ConversationItem
|
||||||
|
key={conversation.partnerPubkey}
|
||||||
|
conversation={conversation}
|
||||||
|
isActive={currentConversation === conversation.partnerPubkey}
|
||||||
|
isFollowing={followingSet.has(conversation.partnerPubkey)}
|
||||||
|
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 */}
|
||||||
|
{hasMoreConversations && (
|
||||||
|
<div ref={loadMoreRef} className="flex justify-center py-4">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
313
src/components/Inbox/ConversationSettingsModal.tsx
Normal file
313
src/components/Inbox/ConversationSettingsModal.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
|
import { TRelayList } from '@/types'
|
||||||
|
import { Check, Loader2, Lock, LockOpen, User, Users, Zap } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type EncryptionPreference = 'auto' | 'nip04' | 'nip17'
|
||||||
|
|
||||||
|
interface ConversationSettingsModalProps {
|
||||||
|
partnerPubkey: string | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
selectedRelays: string[]
|
||||||
|
onSelectedRelaysChange: (relays: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelayInfo = {
|
||||||
|
url: string
|
||||||
|
isYours: boolean
|
||||||
|
isTheirs: boolean
|
||||||
|
isShared: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversationSettingsModal({
|
||||||
|
partnerPubkey,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
selectedRelays,
|
||||||
|
onSelectedRelaysChange
|
||||||
|
}: ConversationSettingsModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { pubkey, relayList: myRelayList, hasNip44Support } = useNostr()
|
||||||
|
const [partnerRelayList, setPartnerRelayList] = useState<TRelayList | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [relays, setRelays] = useState<RelayInfo[]>([])
|
||||||
|
const [encryptionPreference, setEncryptionPreference] = useState<EncryptionPreference>('auto')
|
||||||
|
|
||||||
|
// Fetch partner's relay list when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !partnerPubkey) return
|
||||||
|
|
||||||
|
const fetchPartnerRelays = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const relayList = await client.fetchRelayList(partnerPubkey)
|
||||||
|
setPartnerRelayList(relayList)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch partner relay list:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPartnerRelays()
|
||||||
|
}, [open, partnerPubkey])
|
||||||
|
|
||||||
|
// Load encryption preference when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !partnerPubkey || !pubkey) return
|
||||||
|
|
||||||
|
const loadEncryptionPreference = async () => {
|
||||||
|
const saved = await indexedDb.getConversationEncryptionPreference(pubkey, partnerPubkey)
|
||||||
|
setEncryptionPreference(saved || 'auto')
|
||||||
|
}
|
||||||
|
loadEncryptionPreference()
|
||||||
|
}, [open, partnerPubkey, pubkey])
|
||||||
|
|
||||||
|
// Save encryption preference when it changes
|
||||||
|
const handleEncryptionChange = async (value: EncryptionPreference) => {
|
||||||
|
setEncryptionPreference(value)
|
||||||
|
if (pubkey && partnerPubkey) {
|
||||||
|
await indexedDb.putConversationEncryptionPreference(pubkey, partnerPubkey, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build relay list when data is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!myRelayList || !partnerRelayList) return
|
||||||
|
|
||||||
|
const myWriteRelays = new Set(myRelayList.write.map((r) => r.replace(/\/$/, '')))
|
||||||
|
const theirReadRelays = new Set(partnerRelayList.read.map((r) => r.replace(/\/$/, '')))
|
||||||
|
|
||||||
|
// Combine all relays
|
||||||
|
const allRelayUrls = new Set<string>()
|
||||||
|
myRelayList.write.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
|
||||||
|
partnerRelayList.read.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
|
||||||
|
|
||||||
|
const relayInfos: RelayInfo[] = Array.from(allRelayUrls).map((url) => {
|
||||||
|
const normalizedUrl = url.replace(/\/$/, '')
|
||||||
|
const isYours = myWriteRelays.has(normalizedUrl)
|
||||||
|
const isTheirs = theirReadRelays.has(normalizedUrl)
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
isYours,
|
||||||
|
isTheirs,
|
||||||
|
isShared: isYours && isTheirs
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort: shared first, then yours, then theirs
|
||||||
|
relayInfos.sort((a, b) => {
|
||||||
|
if (a.isShared && !b.isShared) return -1
|
||||||
|
if (!a.isShared && b.isShared) return 1
|
||||||
|
if (a.isYours && !b.isYours) return -1
|
||||||
|
if (!a.isYours && b.isYours) return 1
|
||||||
|
return a.url.localeCompare(b.url)
|
||||||
|
})
|
||||||
|
|
||||||
|
setRelays(relayInfos)
|
||||||
|
|
||||||
|
// If no relays selected yet, default to shared relays
|
||||||
|
if (selectedRelays.length === 0) {
|
||||||
|
const sharedRelays = relayInfos.filter((r) => r.isShared).map((r) => r.url)
|
||||||
|
if (sharedRelays.length > 0) {
|
||||||
|
onSelectedRelaysChange(sharedRelays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [myRelayList, partnerRelayList])
|
||||||
|
|
||||||
|
const toggleRelay = (url: string) => {
|
||||||
|
if (selectedRelays.includes(url)) {
|
||||||
|
onSelectedRelaysChange(selectedRelays.filter((r) => r !== url))
|
||||||
|
} else {
|
||||||
|
onSelectedRelaysChange([...selectedRelays, url])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllShared = () => {
|
||||||
|
const sharedUrls = relays.filter((r) => r.isShared).map((r) => r.url)
|
||||||
|
onSelectedRelaysChange(sharedUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
onSelectedRelaysChange(relays.map((r) => r.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRelayUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : '')
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partnerPubkey || !pubkey) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Conversation Settings')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col gap-4">
|
||||||
|
{/* Encryption Preference */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t('Encryption')}</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={encryptionPreference}
|
||||||
|
onValueChange={(value) => handleEncryptionChange(value as EncryptionPreference)}
|
||||||
|
className="grid grid-cols-3 gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="auto" id="enc-auto" />
|
||||||
|
<Label
|
||||||
|
htmlFor="enc-auto"
|
||||||
|
className="flex items-center gap-1 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<Zap className="size-3" />
|
||||||
|
{t('Auto')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="nip04" id="enc-nip04" />
|
||||||
|
<Label
|
||||||
|
htmlFor="enc-nip04"
|
||||||
|
className="flex items-center gap-1 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<LockOpen className="size-3" />
|
||||||
|
NIP-04
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="nip17"
|
||||||
|
id="enc-nip17"
|
||||||
|
disabled={!hasNip44Support}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="enc-nip17"
|
||||||
|
className={`flex items-center gap-1 text-xs cursor-pointer ${!hasNip44Support ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<Lock className="size-3" />
|
||||||
|
NIP-17
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{encryptionPreference === 'auto'
|
||||||
|
? t('Matches existing conversation encryption, or sends both on first message')
|
||||||
|
: encryptionPreference === 'nip04'
|
||||||
|
? t('Classic encryption (NIP-04) - compatible with all clients')
|
||||||
|
: t('Modern encryption (NIP-17) - more private with metadata protection')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<Label className="text-sm font-medium">{t('Relays')}</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User className="size-3" />
|
||||||
|
<span>{t('You')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="size-3" />
|
||||||
|
<span>{t('Them')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="size-3 rounded bg-green-500/20 border border-green-500/50" />
|
||||||
|
<span>{t('Shared')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Check className="size-3" />
|
||||||
|
<span>{t('Selected for sending')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={selectAllShared}>
|
||||||
|
{t('Select shared')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={selectAll}>
|
||||||
|
{t('Select all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Relay list */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-1 min-h-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : relays.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
{t('No relay information available')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
relays.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay.url}
|
||||||
|
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer hover:bg-accent/50 transition-colors ${
|
||||||
|
relay.isShared ? 'bg-green-500/10 border border-green-500/30' : 'bg-muted/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleRelay(relay.url)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRelays.includes(relay.url)}
|
||||||
|
onCheckedChange={() => toggleRelay(relay.url)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-mono truncate block" title={relay.url}>
|
||||||
|
{formatRelayUrl(relay.url)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{relay.isYours && (
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 dark:text-blue-400"
|
||||||
|
title={t('Your write relay')}
|
||||||
|
>
|
||||||
|
<User className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{relay.isTheirs && (
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-600 dark:text-purple-400"
|
||||||
|
title={t('Their read relay')}
|
||||||
|
>
|
||||||
|
<Users className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info text */}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('Selected relays will be used when sending new messages in this conversation.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/Inbox/InboxContent.tsx
Normal file
40
src/components/Inbox/InboxContent.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import ConversationList from './ConversationList'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
|
||||||
|
export default function InboxContent() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { isLoading, error, refreshConversations } = useDM()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="size-8 animate-spin" />
|
||||||
|
<span className="text-sm">{t('Loading messages...')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 gap-4 text-muted-foreground">
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button onClick={refreshConversations} variant="outline" size="sm" className="gap-2">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
{t('Retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversations list - clicking opens in secondary panel (or overlay on mobile)
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-8rem)]">
|
||||||
|
<ConversationList />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
src/components/Inbox/MessageComposer.tsx
Normal file
148
src/components/Inbox/MessageComposer.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { AlertCircle, ChevronDown, ChevronUp, Loader2, Send } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import { Textarea } from '../ui/textarea'
|
||||||
|
|
||||||
|
export default function MessageComposer() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { sendMessage, currentConversation } = useDM()
|
||||||
|
const { relayList } = useNostr()
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showRelays, setShowRelays] = useState(false)
|
||||||
|
const [selectedRelays, setSelectedRelays] = useState<Set<string>>(new Set())
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
|
const writeRelays = useMemo(() => relayList?.write || [], [relayList])
|
||||||
|
|
||||||
|
// Initialize selected relays when write relays change
|
||||||
|
useEffect(() => {
|
||||||
|
if (writeRelays.length > 0 && selectedRelays.size === 0) {
|
||||||
|
setSelectedRelays(new Set(writeRelays))
|
||||||
|
}
|
||||||
|
}, [writeRelays])
|
||||||
|
|
||||||
|
const toggleRelay = (url: string) => {
|
||||||
|
setSelectedRelays((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(url)) {
|
||||||
|
// Don't allow deselecting all relays
|
||||||
|
if (next.size > 1) {
|
||||||
|
next.delete(url)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.add(url)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!message.trim() || !currentConversation || isSending) return
|
||||||
|
|
||||||
|
setIsSending(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const relaysToUse = Array.from(selectedRelays)
|
||||||
|
await sendMessage(message.trim(), relaysToUse.length > 0 ? relaysToUse : undefined)
|
||||||
|
setMessage('')
|
||||||
|
// Return focus to input after sending
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send message:', err)
|
||||||
|
setError(err instanceof Error ? err.message : t('Failed to send message'))
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format relay URL for display
|
||||||
|
const formatRelayUrl = (url: string) => {
|
||||||
|
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-destructive text-xs">
|
||||||
|
<AlertCircle className="size-3 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Relay selector */}
|
||||||
|
{writeRelays.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRelays(!showRelays)}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{showRelays ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||||
|
<span>
|
||||||
|
{t('Relays')} ({selectedRelays.size}/{writeRelays.length})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showRelays && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{writeRelays.map((url) => (
|
||||||
|
<button
|
||||||
|
key={url}
|
||||||
|
onClick={() => toggleRelay(url)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded-full border transition-colors',
|
||||||
|
selectedRelays.has(url)
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-muted text-muted-foreground border-muted hover:border-primary/50'
|
||||||
|
)}
|
||||||
|
title={url}
|
||||||
|
>
|
||||||
|
{formatRelayUrl(url)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMessage(e.target.value)
|
||||||
|
if (error) setError(null)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={t('Type a message...')}
|
||||||
|
className="min-h-[40px] max-h-32 resize-none"
|
||||||
|
disabled={isSending || !currentConversation}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!message.trim() || isSending || !currentConversation}
|
||||||
|
size="icon"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/components/Inbox/MessageContent.tsx
Normal file
120
src/components/Inbox/MessageContent.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import {
|
||||||
|
EmbeddedEventParser,
|
||||||
|
EmbeddedMentionParser,
|
||||||
|
EmbeddedUrlParser,
|
||||||
|
parseContent
|
||||||
|
} from '@/lib/content-parser'
|
||||||
|
import { toNote, toProfile } from '@/lib/link'
|
||||||
|
import { truncateUrl } from '@/lib/url'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
interface MessageContentProps {
|
||||||
|
content: string
|
||||||
|
className?: string
|
||||||
|
/** If true, links will be styled for dark background (primary-foreground color) */
|
||||||
|
isOwnMessage?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders DM message content with linkified URLs and nostr entities.
|
||||||
|
* - URLs open in new tab
|
||||||
|
* - nostr:npub/nprofile opens user profile in secondary pane
|
||||||
|
* - nostr:note1/nevent opens note in secondary pane
|
||||||
|
*/
|
||||||
|
export default function MessageContent({ content, className, isOwnMessage }: MessageContentProps) {
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
|
||||||
|
const nodes = useMemo(() => {
|
||||||
|
return parseContent(content, [EmbeddedEventParser, EmbeddedMentionParser, EmbeddedUrlParser])
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
const linkClass = cn(
|
||||||
|
'underline cursor-pointer hover:opacity-80',
|
||||||
|
isOwnMessage ? 'text-primary-foreground' : 'text-primary'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('whitespace-pre-wrap break-words', className)}>
|
||||||
|
{nodes.map((node, index) => {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return node.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLs - open in new tab
|
||||||
|
if (node.type === 'url' || node.type === 'image' || node.type === 'media') {
|
||||||
|
const url = node.data as string
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={linkClass}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{truncateUrl(url)}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube and X posts - open in new tab
|
||||||
|
if (node.type === 'youtube' || node.type === 'x-post') {
|
||||||
|
const url = node.data as string
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={linkClass}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{truncateUrl(url)}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nostr: mention (npub/nprofile) - open profile in secondary pane
|
||||||
|
if (node.type === 'mention') {
|
||||||
|
const bech32 = (node.data as string).replace('nostr:', '')
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className={linkClass}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
push(toProfile(bech32))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{bech32.slice(0, 12)}...
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nostr: event (note1/nevent/naddr) - open note in secondary pane
|
||||||
|
if (node.type === 'event') {
|
||||||
|
const bech32 = (node.data as string).replace('nostr:', '')
|
||||||
|
// Determine display based on prefix
|
||||||
|
const isNote = bech32.startsWith('note1')
|
||||||
|
const prefix = isNote ? 'note' : bech32.startsWith('nevent') ? 'nevent' : 'naddr'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className={linkClass}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
push(toNote(bech32))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prefix}:{bech32.slice(prefix.length, prefix.length + 8)}...
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
src/components/Inbox/MessageInfoModal.tsx
Normal file
133
src/components/Inbox/MessageInfoModal.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import dmService from '@/services/dm.service'
|
||||||
|
import { TDirectMessage } from '@/types'
|
||||||
|
import { Loader2, RefreshCw, Server } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface MessageInfoModalProps {
|
||||||
|
message: TDirectMessage | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onRelaysUpdated?: (relays: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageInfoModal({
|
||||||
|
message,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onRelaysUpdated
|
||||||
|
}: MessageInfoModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isChecking, setIsChecking] = useState(false)
|
||||||
|
const [additionalRelays, setAdditionalRelays] = useState<string[]>([])
|
||||||
|
|
||||||
|
if (!message) return null
|
||||||
|
|
||||||
|
const allRelays = [...(message.seenOnRelays || []), ...additionalRelays]
|
||||||
|
const uniqueRelays = [...new Set(allRelays)]
|
||||||
|
|
||||||
|
const handleCheckOtherRelays = async () => {
|
||||||
|
setIsChecking(true)
|
||||||
|
try {
|
||||||
|
const foundRelays = await dmService.checkOtherRelaysForEvent(
|
||||||
|
message.id,
|
||||||
|
uniqueRelays
|
||||||
|
)
|
||||||
|
if (foundRelays.length > 0) {
|
||||||
|
const newRelays = [...additionalRelays, ...foundRelays]
|
||||||
|
setAdditionalRelays(newRelays)
|
||||||
|
onRelaysUpdated?.([...(message.seenOnRelays || []), ...newRelays])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check other relays:', error)
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRelayUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : '')
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Server className="size-4" />
|
||||||
|
{t('Message Info')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Protocol */}
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t('Encryption')}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
{message.encryptionType === 'nip17' ? 'NIP-44 (Gift Wrap)' : 'NIP-04 (Legacy)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Relays */}
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t('Seen on relays')}
|
||||||
|
</span>
|
||||||
|
{uniqueRelays.length > 0 ? (
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{uniqueRelays.map((relay) => (
|
||||||
|
<li
|
||||||
|
key={relay}
|
||||||
|
className="text-sm font-mono bg-muted px-2 py-1 rounded truncate"
|
||||||
|
title={relay}
|
||||||
|
>
|
||||||
|
{formatRelayUrl(relay)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t('No relay information available')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Check other relays button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleCheckOtherRelays}
|
||||||
|
disabled={isChecking}
|
||||||
|
>
|
||||||
|
{isChecking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||||
|
{t('Checking...')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-4 mr-2" />
|
||||||
|
{t('Check for other relays')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
444
src/components/Inbox/MessageView.tsx
Normal file
444
src/components/Inbox/MessageView.tsx
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
|
import { formatTimestamp } from '@/lib/timestamp'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
|
import { TDirectMessage, TProfile } from '@/types'
|
||||||
|
import { ChevronDown, ChevronUp, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import { ScrollArea } from '../ui/scroll-area'
|
||||||
|
import { Checkbox } from '../ui/checkbox'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '../ui/dropdown-menu'
|
||||||
|
import MessageComposer from './MessageComposer'
|
||||||
|
import MessageContent from './MessageContent'
|
||||||
|
import MessageInfoModal from './MessageInfoModal'
|
||||||
|
import ConversationSettingsModal from './ConversationSettingsModal'
|
||||||
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
|
|
||||||
|
interface MessageViewProps {
|
||||||
|
onBack?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageView({ onBack }: MessageViewProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const {
|
||||||
|
currentConversation,
|
||||||
|
messages,
|
||||||
|
isLoadingConversation,
|
||||||
|
isNewConversation,
|
||||||
|
clearNewConversationFlag,
|
||||||
|
reloadConversation,
|
||||||
|
// Selection mode
|
||||||
|
selectedMessages,
|
||||||
|
isSelectionMode,
|
||||||
|
toggleMessageSelection,
|
||||||
|
clearSelection,
|
||||||
|
deleteSelectedMessages,
|
||||||
|
deleteAllInConversation,
|
||||||
|
undeleteAllInConversation
|
||||||
|
} = useDM()
|
||||||
|
const { followingSet } = useFollowList()
|
||||||
|
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [selectedMessage, setSelectedMessage] = useState<TDirectMessage | null>(null)
|
||||||
|
const [messageInfoOpen, setMessageInfoOpen] = useState(false)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [selectedRelays, setSelectedRelays] = useState<string[]>([])
|
||||||
|
const [showPulse, setShowPulse] = useState(false)
|
||||||
|
const [showJumpButton, setShowJumpButton] = useState(false)
|
||||||
|
const [newMessageCount, setNewMessageCount] = useState(0)
|
||||||
|
const lastMessageCountRef = useRef(0)
|
||||||
|
const isAtBottomRef = useRef(true)
|
||||||
|
// Progressive loading: start with 20 messages, load more on demand
|
||||||
|
const [visibleLimit, setVisibleLimit] = useState(20)
|
||||||
|
const LOAD_MORE_INCREMENT = 20
|
||||||
|
|
||||||
|
const isFollowing = currentConversation ? followingSet.has(currentConversation) : false
|
||||||
|
|
||||||
|
// Calculate visible messages (show most recent, allow loading older)
|
||||||
|
const hasMoreMessages = messages.length > visibleLimit
|
||||||
|
const visibleMessages = hasMoreMessages
|
||||||
|
? messages.slice(-visibleLimit) // Show last N messages (most recent)
|
||||||
|
: messages
|
||||||
|
|
||||||
|
// Load more older messages
|
||||||
|
const loadMoreMessages = () => {
|
||||||
|
setVisibleLimit((prev) => prev + LOAD_MORE_INCREMENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset visible limit when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleLimit(20)
|
||||||
|
}, [currentConversation])
|
||||||
|
|
||||||
|
// Handle pulsing animation for new conversations
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNewConversation) {
|
||||||
|
setShowPulse(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowPulse(false)
|
||||||
|
clearNewConversationFlag()
|
||||||
|
}, 10000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isNewConversation, clearNewConversationFlag])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentConversation) return
|
||||||
|
|
||||||
|
const fetchProfileData = async () => {
|
||||||
|
try {
|
||||||
|
const profileData = await client.fetchProfile(currentConversation)
|
||||||
|
if (profileData) {
|
||||||
|
setProfile(profileData)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profile:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProfileData()
|
||||||
|
}, [currentConversation])
|
||||||
|
|
||||||
|
// Load saved relay settings when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentConversation || !pubkey) return
|
||||||
|
|
||||||
|
const loadRelaySettings = async () => {
|
||||||
|
const saved = await indexedDb.getConversationRelaySettings(pubkey, currentConversation)
|
||||||
|
setSelectedRelays(saved || [])
|
||||||
|
}
|
||||||
|
loadRelaySettings()
|
||||||
|
}, [currentConversation, pubkey])
|
||||||
|
|
||||||
|
// Save relay settings when they change
|
||||||
|
const handleRelaysChange = async (relays: string[]) => {
|
||||||
|
setSelectedRelays(relays)
|
||||||
|
if (pubkey && currentConversation) {
|
||||||
|
await indexedDb.putConversationRelaySettings(pubkey, currentConversation, relays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scroll position tracking
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!scrollRef.current) return
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
|
||||||
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||||
|
const atBottom = distanceFromBottom < 100 // 100px threshold
|
||||||
|
|
||||||
|
isAtBottomRef.current = atBottom
|
||||||
|
setShowJumpButton(!atBottom)
|
||||||
|
|
||||||
|
// Reset new message count when user scrolls to bottom
|
||||||
|
if (atBottom) {
|
||||||
|
setNewMessageCount(0)
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track new messages when scrolled up
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtBottomRef.current && messages.length > lastMessageCountRef.current) {
|
||||||
|
setNewMessageCount(messages.length - lastMessageCountRef.current)
|
||||||
|
} else if (isAtBottomRef.current) {
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
}
|
||||||
|
}, [messages.length])
|
||||||
|
|
||||||
|
// Scroll to bottom when messages change (only if already at bottom)
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current && isAtBottomRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
}
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Scroll to bottom function
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTo({
|
||||||
|
top: scrollRef.current.scrollHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
setNewMessageCount(0)
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
isAtBottomRef.current = true
|
||||||
|
setShowJumpButton(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset scroll state when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
isAtBottomRef.current = true
|
||||||
|
setShowJumpButton(false)
|
||||||
|
setNewMessageCount(0)
|
||||||
|
lastMessageCountRef.current = 0
|
||||||
|
}, [currentConversation])
|
||||||
|
|
||||||
|
if (!currentConversation || !pubkey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = profile?.username || currentConversation.slice(0, 8) + '...'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 p-3 border-b">
|
||||||
|
{isSelectionMode ? (
|
||||||
|
// Selection mode header
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={clearSelection}
|
||||||
|
className="size-8"
|
||||||
|
title={t('Cancel')}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
<span className="font-medium text-sm">{t('Delete')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={deleteSelectedMessages}
|
||||||
|
disabled={selectedMessages.size === 0}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t('Selected')} ({selectedMessages.size})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={deleteAllInConversation}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t('All')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Normal header
|
||||||
|
<>
|
||||||
|
<UserAvatar userId={currentConversation} className="size-8" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-medium text-sm truncate">{displayName}</span>
|
||||||
|
{isFollowing && (
|
||||||
|
<span title="Following">
|
||||||
|
<Users className="size-3 text-primary" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{profile?.nip05 && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
title={t('Reload messages')}
|
||||||
|
onClick={reloadConversation}
|
||||||
|
disabled={isLoadingConversation}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
|
||||||
|
title={t('Conversation settings')}
|
||||||
|
onClick={() => {
|
||||||
|
setShowPulse(false)
|
||||||
|
clearNewConversationFlag()
|
||||||
|
setSettingsOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="size-4 mr-2" />
|
||||||
|
{t('Delete All')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={undeleteAllInConversation}>
|
||||||
|
<Undo2 className="size-4 mr-2" />
|
||||||
|
{t('Undelete All')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{onBack && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
title={t('Close conversation')}
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
<ScrollArea ref={scrollRef} className="h-full p-3" onScrollCapture={handleScroll}>
|
||||||
|
{isLoadingConversation && messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<p className="text-sm">{t('No messages yet. Send one to start the conversation!')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Load more button at top */}
|
||||||
|
{hasMoreMessages && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadMoreMessages}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-4 mr-1" />
|
||||||
|
{t('Load older messages')} ({messages.length - visibleLimit} more)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLoadingConversation && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleMessages.map((message) => {
|
||||||
|
const isOwn = message.senderPubkey === pubkey
|
||||||
|
const isSelected = selectedMessages.has(message.id)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-2 group',
|
||||||
|
isOwn ? 'flex-row-reverse' : 'flex-row'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Checkbox - shows on hover or when in selection mode */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 transition-opacity',
|
||||||
|
isSelectionMode || isSelected
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => toggleMessageSelection(message.id)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[80%] rounded-lg px-3 py-2',
|
||||||
|
isOwn
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageContent
|
||||||
|
content={message.content}
|
||||||
|
className="text-sm"
|
||||||
|
isOwnMessage={isOwn}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-2 mt-1 text-xs',
|
||||||
|
isOwn ? 'text-primary-foreground/70' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{formatTimestamp(message.createdAt)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMessage(message)
|
||||||
|
setMessageInfoOpen(true)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'font-mono opacity-60 hover:opacity-100 transition-opacity',
|
||||||
|
isOwn ? 'hover:text-primary-foreground' : 'hover:text-foreground'
|
||||||
|
)}
|
||||||
|
title={t('Message info')}
|
||||||
|
>
|
||||||
|
{message.encryptionType === 'nip17' ? '44' : '4'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Jump to newest button */}
|
||||||
|
{showJumpButton && (
|
||||||
|
<Button
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
className="absolute bottom-4 right-4 rounded-full shadow-lg size-10 p-0"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-5" />
|
||||||
|
{newMessageCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full min-w-5 h-5 flex items-center justify-center text-xs font-medium px-1">
|
||||||
|
{newMessageCount > 99 ? '99+' : newMessageCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div className="border-t">
|
||||||
|
<MessageComposer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Info Modal */}
|
||||||
|
<MessageInfoModal
|
||||||
|
message={selectedMessage}
|
||||||
|
open={messageInfoOpen}
|
||||||
|
onOpenChange={setMessageInfoOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Conversation Settings Modal */}
|
||||||
|
<ConversationSettingsModal
|
||||||
|
partnerPubkey={currentConversation}
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
selectedRelays={selectedRelays}
|
||||||
|
onSelectedRelaysChange={handleRelaysChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
|
||||||
import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants'
|
import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants'
|
||||||
import { getParentStuff, isNsfwEvent } from '@/lib/event'
|
import { getParentStuff, isNsfwEvent } from '@/lib/event'
|
||||||
import { toExternalContent, toNote } from '@/lib/link'
|
import { toExternalContent, toNote } from '@/lib/link'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
@@ -18,6 +20,7 @@ import ParentNotePreview from '../ParentNotePreview'
|
|||||||
import TrustScoreBadge from '../TrustScoreBadge'
|
import TrustScoreBadge from '../TrustScoreBadge'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
import { Mail } from 'lucide-react'
|
||||||
import CommunityDefinition from './CommunityDefinition'
|
import CommunityDefinition from './CommunityDefinition'
|
||||||
import EmojiPack from './EmojiPack'
|
import EmojiPack from './EmojiPack'
|
||||||
import FollowPack from './FollowPack'
|
import FollowPack from './FollowPack'
|
||||||
@@ -50,7 +53,10 @@ export default function Note({
|
|||||||
showFull?: boolean
|
showFull?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
|
const { navigate } = usePrimaryPage()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const { startConversation } = useDM()
|
||||||
const { parentEventId, parentExternalContent } = useMemo(() => {
|
const { parentEventId, parentExternalContent } = useMemo(() => {
|
||||||
return getParentStuff(event)
|
return getParentStuff(event)
|
||||||
}, [event])
|
}, [event])
|
||||||
@@ -58,6 +64,12 @@ export default function Note({
|
|||||||
const [showNsfw, setShowNsfw] = useState(false)
|
const [showNsfw, setShowNsfw] = useState(false)
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const [showMuted, setShowMuted] = useState(false)
|
const [showMuted, setShowMuted] = useState(false)
|
||||||
|
|
||||||
|
const handleStartConversation = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
startConversation(event.pubkey)
|
||||||
|
navigate('inbox')
|
||||||
|
}
|
||||||
const isNsfw = useMemo(
|
const isNsfw = useMemo(
|
||||||
() => (nsfwDisplayPolicy === NSFW_DISPLAY_POLICY.SHOW ? false : isNsfwEvent(event)),
|
() => (nsfwDisplayPolicy === NSFW_DISPLAY_POLICY.SHOW ? false : isNsfwEvent(event)),
|
||||||
[event, nsfwDisplayPolicy]
|
[event, nsfwDisplayPolicy]
|
||||||
@@ -134,6 +146,15 @@ export default function Note({
|
|||||||
<FollowingBadge pubkey={event.pubkey} />
|
<FollowingBadge pubkey={event.pubkey} />
|
||||||
<TrustScoreBadge pubkey={event.pubkey} />
|
<TrustScoreBadge pubkey={event.pubkey} />
|
||||||
<ClientTag event={event} />
|
<ClientTag event={event} />
|
||||||
|
{pubkey && pubkey !== event.pubkey && (
|
||||||
|
<button
|
||||||
|
onClick={handleStartConversation}
|
||||||
|
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Start conversation"
|
||||||
|
>
|
||||||
|
<Mail className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<Nip05 pubkey={event.pubkey} append="·" />
|
<Nip05 pubkey={event.pubkey} append="·" />
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
List,
|
List,
|
||||||
|
MessageSquare,
|
||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
@@ -155,6 +156,9 @@ export default function Settings() {
|
|||||||
// System settings
|
// System settings
|
||||||
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
|
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
|
||||||
|
|
||||||
|
// Messaging settings
|
||||||
|
const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
|
||||||
|
|
||||||
const handleLanguageChange = (value: TLanguage) => {
|
const handleLanguageChange = (value: TLanguage) => {
|
||||||
i18n.changeLanguage(value)
|
i18n.changeLanguage(value)
|
||||||
setLanguage(value)
|
setLanguage(value)
|
||||||
@@ -528,6 +532,37 @@ export default function Settings() {
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Messaging */}
|
||||||
|
{!!pubkey && (
|
||||||
|
<AccordionItem value="messaging">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<MessageSquare className="size-4" />
|
||||||
|
<span>{t('Messaging')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 space-y-4">
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="prefer-nip44" className="text-base font-normal">
|
||||||
|
<div>{t('Prefer NIP-44 encryption')}</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t('Use modern encryption for new conversations')}
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="prefer-nip44"
|
||||||
|
checked={preferNip44}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
storage.setPreferNip44(checked)
|
||||||
|
setPreferNip44(checked)
|
||||||
|
dispatchSettingsChanged()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* System */}
|
{/* System */}
|
||||||
<AccordionItem value="system">
|
<AccordionItem value="system">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { useSecondaryPage, useSidebarDrawer } from '@/PageManager'
|
import { useSecondaryPage, useSidebarDrawer } from '@/PageManager'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { LogIn, LogOut, Plus, Wallet } from 'lucide-react'
|
import { LogIn, LogOut, Plus, RefreshCw, Wallet } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import LoginDialog from '../LoginDialog'
|
import LoginDialog from '../LoginDialog'
|
||||||
@@ -139,6 +139,13 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
|
|||||||
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate"
|
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
<RefreshCw />
|
||||||
|
{t('Force Reload')}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
||||||
|
|||||||
25
src/components/Sidebar/InboxButton.tsx
Normal file
25
src/components/Sidebar/InboxButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
|
export default function InboxButton({ collapse }: { collapse: boolean }) {
|
||||||
|
const { navigate, current, display } = usePrimaryPage()
|
||||||
|
const { hasNewMessages } = useDM()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
title="Inbox"
|
||||||
|
onClick={() => navigate('inbox')}
|
||||||
|
active={display && current === 'inbox'}
|
||||||
|
collapse={collapse}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<MessageSquare />
|
||||||
|
{hasNewMessages && (
|
||||||
|
<div className="absolute -top-1 right-0 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ChevronsLeft, ChevronsRight } from 'lucide-react'
|
|||||||
import AccountButton from './AccountButton'
|
import AccountButton from './AccountButton'
|
||||||
import BookmarkButton from './BookmarkButton'
|
import BookmarkButton from './BookmarkButton'
|
||||||
import HomeButton from './HomeButton'
|
import HomeButton from './HomeButton'
|
||||||
|
import InboxButton from './InboxButton'
|
||||||
import LayoutSwitcher from './LayoutSwitcher'
|
import LayoutSwitcher from './LayoutSwitcher'
|
||||||
import NotificationsButton from './NotificationButton'
|
import NotificationsButton from './NotificationButton'
|
||||||
import PostButton from './PostButton'
|
import PostButton from './PostButton'
|
||||||
@@ -57,6 +58,7 @@ export default function PrimaryPageSidebar() {
|
|||||||
<HomeButton collapse={isCollapsed} />
|
<HomeButton collapse={isCollapsed} />
|
||||||
<NotificationsButton collapse={isCollapsed} />
|
<NotificationsButton collapse={isCollapsed} />
|
||||||
<SearchButton collapse={isCollapsed} />
|
<SearchButton collapse={isCollapsed} />
|
||||||
|
{pubkey && <InboxButton collapse={isCollapsed} />}
|
||||||
<ProfileButton collapse={isCollapsed} />
|
<ProfileButton collapse={isCollapsed} />
|
||||||
{pubkey && <BookmarkButton collapse={isCollapsed} />}
|
{pubkey && <BookmarkButton collapse={isCollapsed} />}
|
||||||
<SettingsButton collapse={isCollapsed} />
|
<SettingsButton collapse={isCollapsed} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
|||||||
import AccountButton from '../Sidebar/AccountButton'
|
import AccountButton from '../Sidebar/AccountButton'
|
||||||
import BookmarkButton from '../Sidebar/BookmarkButton'
|
import BookmarkButton from '../Sidebar/BookmarkButton'
|
||||||
import HomeButton from '../Sidebar/HomeButton'
|
import HomeButton from '../Sidebar/HomeButton'
|
||||||
|
import InboxButton from '../Sidebar/InboxButton'
|
||||||
import LogoutButton from '../Sidebar/LogoutButton'
|
import LogoutButton from '../Sidebar/LogoutButton'
|
||||||
import NotificationsButton from '../Sidebar/NotificationButton'
|
import NotificationsButton from '../Sidebar/NotificationButton'
|
||||||
import ProfileButton from '../Sidebar/ProfileButton'
|
import ProfileButton from '../Sidebar/ProfileButton'
|
||||||
@@ -52,6 +53,11 @@ export default function SidebarDrawer({ open, onOpenChange }: SidebarDrawerProps
|
|||||||
<div onClick={handleItemClick}>
|
<div onClick={handleItemClick}>
|
||||||
<NotificationsButton collapse={false} />
|
<NotificationsButton collapse={false} />
|
||||||
</div>
|
</div>
|
||||||
|
{pubkey && (
|
||||||
|
<div onClick={handleItemClick}>
|
||||||
|
<InboxButton collapse={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div onClick={handleItemClick}>
|
<div onClick={handleItemClick}>
|
||||||
<ProfileButton collapse={false} />
|
<ProfileButton collapse={false} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export const StorageKey = {
|
|||||||
QUICK_REACTION: 'quickReaction',
|
QUICK_REACTION: 'quickReaction',
|
||||||
QUICK_REACTION_EMOJI: 'quickReactionEmoji',
|
QUICK_REACTION_EMOJI: 'quickReactionEmoji',
|
||||||
NSFW_DISPLAY_POLICY: 'nsfwDisplayPolicy',
|
NSFW_DISPLAY_POLICY: 'nsfwDisplayPolicy',
|
||||||
|
PREFER_NIP44: 'preferNip44',
|
||||||
|
DM_CONVERSATION_FILTER: 'dmConversationFilter',
|
||||||
|
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
|
||||||
|
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
|
||||||
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
|
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
|
||||||
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
|
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
|
||||||
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
||||||
@@ -58,7 +62,10 @@ export const StorageKey = {
|
|||||||
|
|
||||||
export const ApplicationDataKey = {
|
export const ApplicationDataKey = {
|
||||||
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at',
|
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at',
|
||||||
SETTINGS: 'smesh_settings'
|
SETTINGS: 'smesh_settings',
|
||||||
|
DM_DELETED_MESSAGES: 'dm_deleted_messages',
|
||||||
|
// Relay hint for DMs - contains bech32-encoded relays (nrelay1...) that smesh clients should check first
|
||||||
|
DM_RELAY_HINT: 'smesh_dm_relays'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BIG_RELAY_URLS = [
|
export const BIG_RELAY_URLS = [
|
||||||
@@ -69,6 +76,8 @@ export const BIG_RELAY_URLS = [
|
|||||||
'wss://relay.orly.dev/'
|
'wss://relay.orly.dev/'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const ARCHIVE_RELAY_URL = 'wss://archive.orly.dev/'
|
||||||
|
|
||||||
export const SEARCHABLE_RELAY_URLS = [
|
export const SEARCHABLE_RELAY_URLS = [
|
||||||
'wss://search.nos.today/',
|
'wss://search.nos.today/',
|
||||||
'wss://relay.ditto.pub/',
|
'wss://relay.ditto.pub/',
|
||||||
@@ -80,6 +89,8 @@ export const SEARCHABLE_RELAY_URLS = [
|
|||||||
export const GROUP_METADATA_EVENT_KIND = 39000
|
export const GROUP_METADATA_EVENT_KIND = 39000
|
||||||
|
|
||||||
export const ExtendedKind = {
|
export const ExtendedKind = {
|
||||||
|
SEAL: 13,
|
||||||
|
PRIVATE_DM: 14,
|
||||||
EXTERNAL_CONTENT_REACTION: 17,
|
EXTERNAL_CONTENT_REACTION: 17,
|
||||||
PICTURE: 20,
|
PICTURE: 20,
|
||||||
VIDEO: 21,
|
VIDEO: 21,
|
||||||
@@ -88,6 +99,7 @@ export const ExtendedKind = {
|
|||||||
POLL_RESPONSE: 1018,
|
POLL_RESPONSE: 1018,
|
||||||
COMMENT: 1111,
|
COMMENT: 1111,
|
||||||
VOICE: 1222,
|
VOICE: 1222,
|
||||||
|
GIFT_WRAP: 1059,
|
||||||
VOICE_COMMENT: 1244,
|
VOICE_COMMENT: 1244,
|
||||||
PINNED_USERS: 10010,
|
PINNED_USERS: 10010,
|
||||||
FAVORITE_RELAYS: 10012,
|
FAVORITE_RELAYS: 10012,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Represents the type of signer/authentication method used for an account.
|
* Represents the type of signer/authentication method used for an account.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const VALID_SIGNER_TYPES = ['nsec', 'nip-07', 'browser-nsec', 'ncryptsec', 'npub'] as const
|
const VALID_SIGNER_TYPES = ['nsec', 'nip-07', 'browser-nsec', 'ncryptsec', 'npub', 'bunker'] as const
|
||||||
|
|
||||||
export type SignerTypeValue = (typeof VALID_SIGNER_TYPES)[number]
|
export type SignerTypeValue = (typeof VALID_SIGNER_TYPES)[number]
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ export class SignerType {
|
|||||||
static readonly BROWSER_NSEC = new SignerType('browser-nsec')
|
static readonly BROWSER_NSEC = new SignerType('browser-nsec')
|
||||||
static readonly NCRYPTSEC = new SignerType('ncryptsec')
|
static readonly NCRYPTSEC = new SignerType('ncryptsec')
|
||||||
static readonly NPUB = new SignerType('npub')
|
static readonly NPUB = new SignerType('npub')
|
||||||
|
static readonly BUNKER = new SignerType('bunker')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a SignerType from a string value
|
* Create a SignerType from a string value
|
||||||
@@ -87,7 +88,7 @@ export class SignerType {
|
|||||||
* Whether this signer uses a remote/external service
|
* Whether this signer uses a remote/external service
|
||||||
*/
|
*/
|
||||||
get isRemote(): boolean {
|
get isRemote(): boolean {
|
||||||
return this._value === 'nip-07'
|
return this._value === 'nip-07' || this._value === 'bunker'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +113,8 @@ export class SignerType {
|
|||||||
return 'Encrypted Key'
|
return 'Encrypted Key'
|
||||||
case 'npub':
|
case 'npub':
|
||||||
return 'View Only'
|
return 'View Only'
|
||||||
|
case 'bunker':
|
||||||
|
return 'Remote Signer'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import client from '@/services/client.service'
|
|||||||
import customEmojiService from '@/services/custom-emoji.service'
|
import customEmojiService from '@/services/custom-emoji.service'
|
||||||
import mediaUpload from '@/services/media-upload.service'
|
import mediaUpload from '@/services/media-upload.service'
|
||||||
import {
|
import {
|
||||||
|
TDMDeletedState,
|
||||||
TDraftEvent,
|
TDraftEvent,
|
||||||
TEmoji,
|
TEmoji,
|
||||||
TMailboxRelay,
|
TMailboxRelay,
|
||||||
@@ -453,6 +454,15 @@ export function createSettingsDraftEvent(settings: TSyncSettings): TDraftEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createDeletedMessagesDraftEvent(deletedState: TDMDeletedState): TDraftEvent {
|
||||||
|
return {
|
||||||
|
kind: kinds.Application,
|
||||||
|
content: JSON.stringify(deletedState),
|
||||||
|
tags: [buildDTag(ApplicationDataKey.DM_DELETED_MESSAGES)],
|
||||||
|
created_at: dayjs().unix()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraftEvent {
|
export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraftEvent {
|
||||||
return {
|
return {
|
||||||
kind: kinds.BookmarkList,
|
kind: kinds.BookmarkList,
|
||||||
|
|||||||
@@ -92,3 +92,7 @@ export const toUserAggregationDetail = (feedId: string, pubkey: string) => {
|
|||||||
}
|
}
|
||||||
export const toLogin = () => '/login'
|
export const toLogin = () => '/login'
|
||||||
export const toLogout = () => '/logout'
|
export const toLogout = () => '/logout'
|
||||||
|
export const toDMConversation = (pubkey: string) => {
|
||||||
|
const npub = pubkey.startsWith('npub') ? pubkey : nip19.npubEncode(pubkey)
|
||||||
|
return `/dm/${npub}`
|
||||||
|
}
|
||||||
|
|||||||
29
src/lib/timestamp.ts
Normal file
29
src/lib/timestamp.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a unix timestamp (seconds) to a relative time string.
|
||||||
|
* e.g., "5 minutes ago", "2 hours ago", "3 days ago"
|
||||||
|
*/
|
||||||
|
export function formatTimestamp(timestamp: number): string {
|
||||||
|
return dayjs.unix(timestamp).fromNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a unix timestamp to a short time string.
|
||||||
|
* Shows time for today, date for older messages.
|
||||||
|
*/
|
||||||
|
export function formatMessageTime(timestamp: number): string {
|
||||||
|
const date = dayjs.unix(timestamp)
|
||||||
|
const now = dayjs()
|
||||||
|
|
||||||
|
if (date.isSame(now, 'day')) {
|
||||||
|
return date.format('HH:mm')
|
||||||
|
} else if (date.isSame(now, 'year')) {
|
||||||
|
return date.format('MMM D')
|
||||||
|
} else {
|
||||||
|
return date.format('MMM D, YYYY')
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/pages/primary/InboxPage/index.tsx
Normal file
64
src/pages/primary/InboxPage/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import InboxContent from '@/components/Inbox/InboxContent'
|
||||||
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { TPageRef } from '@/types'
|
||||||
|
import { MessageSquare, LogIn } from 'lucide-react'
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const InboxPage = forwardRef<TPageRef>((_, ref) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const layoutRef = useRef<TPageRef>(null)
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const { navigate } = usePrimaryPage()
|
||||||
|
const { markInboxAsSeen } = useDM()
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => layoutRef.current as TPageRef)
|
||||||
|
|
||||||
|
// Mark inbox as seen when page is viewed
|
||||||
|
useEffect(() => {
|
||||||
|
if (pubkey) {
|
||||||
|
markInboxAsSeen()
|
||||||
|
}
|
||||||
|
}, [pubkey, markInboxAsSeen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimaryPageLayout
|
||||||
|
pageName="inbox"
|
||||||
|
ref={layoutRef}
|
||||||
|
titlebar={<InboxTitlebar />}
|
||||||
|
displayScrollToTopButton
|
||||||
|
>
|
||||||
|
{pubkey ? (
|
||||||
|
<InboxContent />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 gap-4 text-muted-foreground">
|
||||||
|
<MessageSquare className="size-12" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-medium">{t('Sign in to view your messages')}</p>
|
||||||
|
<p className="text-sm">{t('Your encrypted conversations will appear here')}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => navigate('settings')} className="gap-2">
|
||||||
|
<LogIn className="size-4" />
|
||||||
|
{t('Sign In')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PrimaryPageLayout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
InboxPage.displayName = 'InboxPage'
|
||||||
|
export default InboxPage
|
||||||
|
|
||||||
|
function InboxTitlebar() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center h-full pl-3">
|
||||||
|
<MessageSquare className="size-5" />
|
||||||
|
<div className="text-lg font-semibold">{t('Inbox')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/pages/secondary/DMConversationPage/index.tsx
Normal file
66
src/pages/secondary/DMConversationPage/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import MessageView from '@/components/Inbox/MessageView'
|
||||||
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { TPageRef } from '@/types'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface DMConversationPageProps {
|
||||||
|
pubkey?: string
|
||||||
|
index?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubkey, index }, ref) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const layoutRef = useRef<TPageRef>(null)
|
||||||
|
const { selectConversation, currentConversation } = useDM()
|
||||||
|
const { pop } = useSecondaryPage()
|
||||||
|
|
||||||
|
// Decode npub to hex if needed
|
||||||
|
const hexPubkey = useMemo(() => {
|
||||||
|
if (!pubkey) return null
|
||||||
|
if (pubkey.startsWith('npub')) {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(pubkey)
|
||||||
|
return decoded.type === 'npub' ? decoded.data : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pubkey
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => layoutRef.current as TPageRef)
|
||||||
|
|
||||||
|
// Select the conversation when this page mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (hexPubkey && hexPubkey !== currentConversation) {
|
||||||
|
selectConversation(hexPubkey)
|
||||||
|
}
|
||||||
|
}, [hexPubkey, selectConversation, currentConversation])
|
||||||
|
|
||||||
|
// Clear conversation when page unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
selectConversation(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
selectConversation(null)
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout ref={layoutRef} index={index} title={t('Conversation')}>
|
||||||
|
<div className="h-full">
|
||||||
|
<MessageView onBack={handleBack} />
|
||||||
|
</div>
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
DMConversationPage.displayName = 'DMConversationPage'
|
||||||
|
export default DMConversationPage
|
||||||
949
src/providers/DMProvider.tsx
Normal file
949
src/providers/DMProvider.tsx
Normal file
@@ -0,0 +1,949 @@
|
|||||||
|
import { ApplicationDataKey, BIG_RELAY_URLS } from '@/constants'
|
||||||
|
import { createDeletedMessagesDraftEvent } from '@/lib/draft-event'
|
||||||
|
import dmService, {
|
||||||
|
clearPlaintextCache,
|
||||||
|
decryptMessagesInBatches,
|
||||||
|
IDMEncryption,
|
||||||
|
isConversationDeleted,
|
||||||
|
isMessageDeleted
|
||||||
|
} from '@/services/dm.service'
|
||||||
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
|
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import { TConversation, TDirectMessage, TDMDeletedState, TDMEncryptionType } from '@/types'
|
||||||
|
import { Event, kinds } from 'nostr-tools'
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNostr } from './NostrProvider'
|
||||||
|
|
||||||
|
type TDMContext = {
|
||||||
|
conversations: TConversation[]
|
||||||
|
currentConversation: string | null
|
||||||
|
messages: TDirectMessage[]
|
||||||
|
isLoading: boolean
|
||||||
|
isLoadingConversation: boolean
|
||||||
|
error: string | null
|
||||||
|
selectConversation: (partnerPubkey: string | null) => void
|
||||||
|
startConversation: (partnerPubkey: string) => void
|
||||||
|
sendMessage: (content: string, customRelayUrls?: string[]) => Promise<void>
|
||||||
|
refreshConversations: () => Promise<void>
|
||||||
|
reloadConversation: () => void
|
||||||
|
loadMoreConversations: () => Promise<void>
|
||||||
|
hasMoreConversations: boolean
|
||||||
|
preferNip44: boolean
|
||||||
|
setPreferNip44: (prefer: boolean) => void
|
||||||
|
isNewConversation: boolean
|
||||||
|
clearNewConversationFlag: () => void
|
||||||
|
// Unread tracking
|
||||||
|
totalUnreadCount: number
|
||||||
|
hasNewMessages: boolean
|
||||||
|
markInboxAsSeen: () => void
|
||||||
|
// Selection mode
|
||||||
|
selectedMessages: Set<string>
|
||||||
|
isSelectionMode: boolean
|
||||||
|
toggleMessageSelection: (messageId: string) => void
|
||||||
|
selectAllMessages: () => void
|
||||||
|
clearSelection: () => void
|
||||||
|
// Deletion
|
||||||
|
deleteSelectedMessages: () => Promise<void>
|
||||||
|
deleteAllInConversation: () => Promise<void>
|
||||||
|
undeleteAllInConversation: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DMContext = createContext<TDMContext | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useDM = () => {
|
||||||
|
const context = useContext(DMContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDM must be used within a DMProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DMProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const {
|
||||||
|
pubkey,
|
||||||
|
relayList,
|
||||||
|
nip04Encrypt,
|
||||||
|
nip04Decrypt,
|
||||||
|
nip44Encrypt,
|
||||||
|
nip44Decrypt,
|
||||||
|
hasNip44Support,
|
||||||
|
signEvent
|
||||||
|
} = useNostr()
|
||||||
|
|
||||||
|
const [conversations, setConversations] = useState<TConversation[]>([])
|
||||||
|
const [allConversations, setAllConversations] = useState<TConversation[]>([])
|
||||||
|
const [currentConversation, setCurrentConversation] = useState<string | null>(null)
|
||||||
|
const [messages, setMessages] = useState<TDirectMessage[]>([])
|
||||||
|
const [conversationMessages, setConversationMessages] = useState<Map<string, TDirectMessage[]>>(
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
const [loadedConversations, setLoadedConversations] = useState<Set<string>>(new Set())
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isLoadingConversation, setIsLoadingConversation] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
|
||||||
|
const [hasMoreConversations, setHasMoreConversations] = useState(false)
|
||||||
|
const [isNewConversation, setIsNewConversation] = useState(false)
|
||||||
|
const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
|
||||||
|
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
|
||||||
|
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||||
|
const [lastSeenTimestamp, setLastSeenTimestamp] = useState<number>(() =>
|
||||||
|
pubkey ? storage.getDMLastSeenTimestamp(pubkey) : 0
|
||||||
|
)
|
||||||
|
const CONVERSATIONS_PER_PAGE = 100
|
||||||
|
|
||||||
|
// Track which conversation load is in progress to prevent race conditions
|
||||||
|
const loadingConversationRef = useRef<string | null>(null)
|
||||||
|
// Track if we've already initialized to avoid reloading on navigation
|
||||||
|
const hasInitializedRef = useRef(false)
|
||||||
|
const lastPubkeyRef = useRef<string | null>(null)
|
||||||
|
// Background subscription for real-time DM updates
|
||||||
|
const dmSubscriptionRef = useRef<{ close: () => void } | null>(null)
|
||||||
|
// Track newest message timestamp from subscription (to update hasNewMessages)
|
||||||
|
const [newestIncomingTimestamp, setNewestIncomingTimestamp] = useState(0)
|
||||||
|
|
||||||
|
// Create encryption wrapper object for dm.service
|
||||||
|
const encryption: IDMEncryption | null = useMemo(() => {
|
||||||
|
if (!pubkey) return null
|
||||||
|
return {
|
||||||
|
nip04Encrypt,
|
||||||
|
nip04Decrypt,
|
||||||
|
nip44Encrypt: hasNip44Support ? nip44Encrypt : undefined,
|
||||||
|
nip44Decrypt: hasNip44Support ? nip44Decrypt : undefined,
|
||||||
|
signEvent,
|
||||||
|
getPublicKey: () => pubkey
|
||||||
|
}
|
||||||
|
}, [pubkey, nip04Encrypt, nip04Decrypt, nip44Encrypt, nip44Decrypt, hasNip44Support, signEvent])
|
||||||
|
|
||||||
|
// Load deleted state and conversations when user is logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (pubkey && encryption) {
|
||||||
|
// Skip if already initialized for this pubkey (e.g., navigating back)
|
||||||
|
if (hasInitializedRef.current && lastPubkeyRef.current === pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as initialized for this pubkey
|
||||||
|
hasInitializedRef.current = true
|
||||||
|
lastPubkeyRef.current = pubkey
|
||||||
|
|
||||||
|
// Load deleted state FIRST before anything else
|
||||||
|
const loadDeletedStateAndConversations = async () => {
|
||||||
|
// Step 1: Load deleted state from IndexedDB
|
||||||
|
let currentDeletedState: TDMDeletedState = { deletedIds: [], deletedRanges: {} }
|
||||||
|
const cached = await indexedDb.getDeletedMessagesState(pubkey)
|
||||||
|
if (cached) {
|
||||||
|
currentDeletedState = cached
|
||||||
|
setDeletedState(cached)
|
||||||
|
} else {
|
||||||
|
setDeletedState(currentDeletedState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch from relays (kind 30078 Application Specific Data) - this takes priority
|
||||||
|
try {
|
||||||
|
const relayUrls = relayList?.read || BIG_RELAY_URLS
|
||||||
|
const events = await client.fetchEvents(relayUrls, {
|
||||||
|
kinds: [kinds.Application],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [ApplicationDataKey.DM_DELETED_MESSAGES],
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
if (events.length > 0) {
|
||||||
|
const event = events[0]
|
||||||
|
try {
|
||||||
|
const parsedState = JSON.parse(event.content) as TDMDeletedState
|
||||||
|
currentDeletedState = parsedState
|
||||||
|
setDeletedState(parsedState)
|
||||||
|
await indexedDb.putDeletedMessagesState(pubkey, parsedState)
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Relay fetch failed, use cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Load cached conversations (filtered by deleted state)
|
||||||
|
const cachedConvs = await indexedDb.getDMConversations(pubkey)
|
||||||
|
if (cachedConvs.length > 0) {
|
||||||
|
const conversations: TConversation[] = cachedConvs
|
||||||
|
.filter((c) => c.partnerPubkey && typeof c.partnerPubkey === 'string')
|
||||||
|
.filter((c) => !isConversationDeleted(c.partnerPubkey, c.lastMessageAt, currentDeletedState))
|
||||||
|
.map((c) => ({
|
||||||
|
partnerPubkey: c.partnerPubkey,
|
||||||
|
lastMessageAt: c.lastMessageAt,
|
||||||
|
lastMessagePreview: c.lastMessagePreview || '',
|
||||||
|
unreadCount: 0,
|
||||||
|
preferredEncryption: c.encryptionType
|
||||||
|
}))
|
||||||
|
setAllConversations(conversations)
|
||||||
|
setConversations(conversations.slice(0, CONVERSATIONS_PER_PAGE))
|
||||||
|
setHasMoreConversations(conversations.length > CONVERSATIONS_PER_PAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Background refresh from network (don't clear existing data)
|
||||||
|
backgroundRefreshConversations()
|
||||||
|
|
||||||
|
// Step 5: Start real-time subscription for new DMs
|
||||||
|
if (dmSubscriptionRef.current) {
|
||||||
|
dmSubscriptionRef.current.close()
|
||||||
|
}
|
||||||
|
const relayUrls = relayList?.read || []
|
||||||
|
dmSubscriptionRef.current = dmService.subscribeToDMs(pubkey, relayUrls, (event) => {
|
||||||
|
// New DM event received - update timestamp to trigger hasNewMessages
|
||||||
|
setNewestIncomingTimestamp(event.created_at)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDeletedStateAndConversations()
|
||||||
|
} else {
|
||||||
|
// Clear all state on logout
|
||||||
|
setConversations([])
|
||||||
|
setAllConversations([])
|
||||||
|
setMessages([])
|
||||||
|
setConversationMessages(new Map())
|
||||||
|
setLoadedConversations(new Set())
|
||||||
|
setCurrentConversation(null)
|
||||||
|
setDeletedState(null)
|
||||||
|
setSelectedMessages(new Set())
|
||||||
|
setIsSelectionMode(false)
|
||||||
|
// Clear in-memory plaintext cache
|
||||||
|
clearPlaintextCache()
|
||||||
|
// Stop DM subscription
|
||||||
|
if (dmSubscriptionRef.current) {
|
||||||
|
dmSubscriptionRef.current.close()
|
||||||
|
dmSubscriptionRef.current = null
|
||||||
|
}
|
||||||
|
// Reset initialization flag so we reload on next login
|
||||||
|
hasInitializedRef.current = false
|
||||||
|
lastPubkeyRef.current = null
|
||||||
|
}
|
||||||
|
}, [pubkey, encryption, relayList])
|
||||||
|
|
||||||
|
// Load full conversation when selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentConversation || !pubkey || !encryption) {
|
||||||
|
setMessages([])
|
||||||
|
loadingConversationRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the conversation we're loading to detect stale updates
|
||||||
|
const targetConversation = currentConversation
|
||||||
|
loadingConversationRef.current = targetConversation
|
||||||
|
|
||||||
|
// Check if we already have messages in memory for this conversation
|
||||||
|
const existing = conversationMessages.get(targetConversation)
|
||||||
|
if (existing) {
|
||||||
|
setMessages(existing)
|
||||||
|
// If already fully loaded, don't fetch again
|
||||||
|
if (loadedConversations.has(targetConversation)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load full conversation history
|
||||||
|
const loadConversation = async () => {
|
||||||
|
setIsLoadingConversation(true)
|
||||||
|
try {
|
||||||
|
// First, try to load from IndexedDB cache for instant display
|
||||||
|
const cached = await indexedDb.getConversationMessages(pubkey, targetConversation)
|
||||||
|
if (cached && cached.length > 0 && loadingConversationRef.current === targetConversation) {
|
||||||
|
const cachedMessages: TDirectMessage[] = cached
|
||||||
|
.filter(
|
||||||
|
(m) => !isMessageDeleted(m.id, targetConversation, m.createdAt, deletedState)
|
||||||
|
)
|
||||||
|
.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
senderPubkey: m.senderPubkey,
|
||||||
|
recipientPubkey: m.recipientPubkey,
|
||||||
|
content: m.content,
|
||||||
|
createdAt: m.createdAt,
|
||||||
|
encryptionType: m.encryptionType,
|
||||||
|
event: {} as Event,
|
||||||
|
decryptedContent: m.content,
|
||||||
|
seenOnRelays: m.seenOnRelays
|
||||||
|
}))
|
||||||
|
setMessages(cachedMessages)
|
||||||
|
setConversationMessages((prev) => new Map(prev).set(targetConversation, cachedMessages))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then fetch fresh from relays
|
||||||
|
const relayUrls = relayList?.read || []
|
||||||
|
const events = await dmService.fetchConversationEvents(pubkey, targetConversation, relayUrls)
|
||||||
|
|
||||||
|
// Check if user switched to a different conversation while we were loading
|
||||||
|
if (loadingConversationRef.current !== targetConversation) {
|
||||||
|
return // Abort - user switched conversations
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt messages in batches to avoid blocking UI
|
||||||
|
// Progressive updates: show messages as they're decrypted
|
||||||
|
const allDecrypted: TDirectMessage[] = []
|
||||||
|
|
||||||
|
await decryptMessagesInBatches(
|
||||||
|
events,
|
||||||
|
encryption,
|
||||||
|
pubkey,
|
||||||
|
10, // batch size
|
||||||
|
(batchMessages) => {
|
||||||
|
// Check if still on same conversation before updating
|
||||||
|
if (loadingConversationRef.current !== targetConversation) return
|
||||||
|
|
||||||
|
// Filter to only messages in this conversation (excluding deleted)
|
||||||
|
const validMessages = batchMessages.filter((message) => {
|
||||||
|
const partner =
|
||||||
|
message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
|
||||||
|
if (partner !== targetConversation) return false
|
||||||
|
return !isMessageDeleted(message.id, targetConversation, message.createdAt, deletedState)
|
||||||
|
})
|
||||||
|
|
||||||
|
allDecrypted.push(...validMessages)
|
||||||
|
|
||||||
|
// Sort and update progressively
|
||||||
|
const sorted = [...allDecrypted].sort((a, b) => a.createdAt - b.createdAt)
|
||||||
|
setMessages(sorted)
|
||||||
|
setConversationMessages((prev) => new Map(prev).set(targetConversation, sorted))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check again after decryption (which can take time)
|
||||||
|
if (loadingConversationRef.current !== targetConversation) {
|
||||||
|
return // Abort - user switched conversations
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final sort
|
||||||
|
const sorted = allDecrypted.sort((a, b) => a.createdAt - b.createdAt)
|
||||||
|
|
||||||
|
// Update state only if still on same conversation
|
||||||
|
setConversationMessages((prev) => new Map(prev).set(targetConversation, sorted))
|
||||||
|
setLoadedConversations((prev) => new Set(prev).add(targetConversation))
|
||||||
|
setMessages(sorted)
|
||||||
|
|
||||||
|
// Cache messages to IndexedDB (without the full event object)
|
||||||
|
const toCache = sorted.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
senderPubkey: m.senderPubkey,
|
||||||
|
recipientPubkey: m.recipientPubkey,
|
||||||
|
content: m.decryptedContent || m.content,
|
||||||
|
createdAt: m.createdAt,
|
||||||
|
encryptionType: m.encryptionType,
|
||||||
|
seenOnRelays: m.seenOnRelays
|
||||||
|
}))
|
||||||
|
await indexedDb.putConversationMessages(pubkey, targetConversation, toCache)
|
||||||
|
} catch {
|
||||||
|
// Failed to load conversation
|
||||||
|
} finally {
|
||||||
|
// Only clear loading state if this is still the active load
|
||||||
|
if (loadingConversationRef.current === targetConversation) {
|
||||||
|
setIsLoadingConversation(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConversation()
|
||||||
|
}, [currentConversation, pubkey, encryption, relayList, deletedState])
|
||||||
|
|
||||||
|
// Background refresh - merges new data without clearing existing cache
|
||||||
|
const backgroundRefreshConversations = useCallback(async () => {
|
||||||
|
if (!pubkey || !encryption) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get relay URLs
|
||||||
|
const relayUrls = relayList?.read || []
|
||||||
|
|
||||||
|
// Fetch recent DM events (raw, not decrypted)
|
||||||
|
const events = await dmService.fetchRecentDMEvents(pubkey, relayUrls)
|
||||||
|
|
||||||
|
// Separate NIP-04 events and gift wraps
|
||||||
|
const nip04Events = events.filter((e) => e.kind === 4)
|
||||||
|
const giftWraps = events.filter((e) => e.kind === 1059)
|
||||||
|
|
||||||
|
// Build conversation map from existing conversations
|
||||||
|
const conversationMap = new Map<string, TConversation>()
|
||||||
|
allConversations.forEach((c) => conversationMap.set(c.partnerPubkey, c))
|
||||||
|
|
||||||
|
// Add NIP-04 conversations
|
||||||
|
const nip04Convs = dmService.groupEventsIntoConversations(nip04Events, pubkey)
|
||||||
|
nip04Convs.forEach((conv, key) => {
|
||||||
|
const existing = conversationMap.get(key)
|
||||||
|
if (!existing || conv.lastMessageAt > existing.lastMessageAt) {
|
||||||
|
conversationMap.set(key, conv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update UI with NIP-04 data (filtered by deleted state)
|
||||||
|
const updateAndShowConversations = () => {
|
||||||
|
const validConversations = Array.from(conversationMap.values())
|
||||||
|
.filter((conv) => conv.partnerPubkey && typeof conv.partnerPubkey === 'string')
|
||||||
|
.filter((conv) => !isConversationDeleted(conv.partnerPubkey, conv.lastMessageAt, deletedState))
|
||||||
|
const sortedConversations = validConversations.sort(
|
||||||
|
(a, b) => b.lastMessageAt - a.lastMessageAt
|
||||||
|
)
|
||||||
|
setAllConversations(sortedConversations)
|
||||||
|
setConversations(sortedConversations.slice(0, CONVERSATIONS_PER_PAGE))
|
||||||
|
setHasMoreConversations(sortedConversations.length > CONVERSATIONS_PER_PAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAndShowConversations()
|
||||||
|
|
||||||
|
// Process gift wraps in background (progressive, no UI blocking)
|
||||||
|
const sortedGiftWraps = giftWraps.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
|
for (const giftWrap of sortedGiftWraps) {
|
||||||
|
try {
|
||||||
|
const message = await dmService.decryptMessage(giftWrap, encryption, pubkey)
|
||||||
|
if (message && message.senderPubkey && message.recipientPubkey) {
|
||||||
|
const partnerPubkey =
|
||||||
|
message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
|
||||||
|
|
||||||
|
if (!partnerPubkey || partnerPubkey === '__reaction__') continue
|
||||||
|
|
||||||
|
const existing = conversationMap.get(partnerPubkey)
|
||||||
|
if (!existing || message.createdAt > existing.lastMessageAt) {
|
||||||
|
conversationMap.set(partnerPubkey, {
|
||||||
|
partnerPubkey,
|
||||||
|
lastMessageAt: message.createdAt,
|
||||||
|
lastMessagePreview: message.content.substring(0, 100),
|
||||||
|
unreadCount: 0,
|
||||||
|
preferredEncryption: 'nip17'
|
||||||
|
})
|
||||||
|
updateAndShowConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache conversation metadata
|
||||||
|
indexedDb
|
||||||
|
.putDMConversation(
|
||||||
|
pubkey,
|
||||||
|
partnerPubkey,
|
||||||
|
message.createdAt,
|
||||||
|
message.content.substring(0, 100),
|
||||||
|
'nip17'
|
||||||
|
)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip failed decryptions silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final update and cache all conversations
|
||||||
|
updateAndShowConversations()
|
||||||
|
const finalConversations = Array.from(conversationMap.values())
|
||||||
|
Promise.all(
|
||||||
|
finalConversations.map((conv) =>
|
||||||
|
indexedDb.putDMConversation(
|
||||||
|
pubkey,
|
||||||
|
conv.partnerPubkey,
|
||||||
|
conv.lastMessageAt,
|
||||||
|
conv.lastMessagePreview,
|
||||||
|
conv.preferredEncryption
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).catch(() => {})
|
||||||
|
} catch {
|
||||||
|
// Background refresh failed silently - cached data still shown
|
||||||
|
}
|
||||||
|
}, [pubkey, encryption, relayList, deletedState, allConversations])
|
||||||
|
|
||||||
|
// Full refresh - fetches fresh data from network (manual action)
|
||||||
|
const refreshConversations = useCallback(async () => {
|
||||||
|
if (!pubkey || !encryption) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get relay URLs
|
||||||
|
const relayUrls = relayList?.read || []
|
||||||
|
|
||||||
|
// Fetch recent DM events (raw, not decrypted)
|
||||||
|
const events = await dmService.fetchRecentDMEvents(pubkey, relayUrls)
|
||||||
|
|
||||||
|
// Separate NIP-04 events and gift wraps
|
||||||
|
const nip04Events = events.filter((e) => e.kind === 4)
|
||||||
|
const giftWraps = events.filter((e) => e.kind === 1059)
|
||||||
|
|
||||||
|
// Build conversation map from existing conversations (merge, don't replace)
|
||||||
|
const conversationMap = new Map<string, TConversation>()
|
||||||
|
allConversations.forEach((c) => conversationMap.set(c.partnerPubkey, c))
|
||||||
|
|
||||||
|
// Add NIP-04 conversations
|
||||||
|
const nip04Convs = dmService.groupEventsIntoConversations(nip04Events, pubkey)
|
||||||
|
nip04Convs.forEach((conv, key) => {
|
||||||
|
const existing = conversationMap.get(key)
|
||||||
|
if (!existing || conv.lastMessageAt > existing.lastMessageAt) {
|
||||||
|
conversationMap.set(key, conv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show NIP-04 conversations immediately (filtered by deleted state)
|
||||||
|
const updateAndShowConversations = () => {
|
||||||
|
const validConversations = Array.from(conversationMap.values())
|
||||||
|
.filter((conv) => conv.partnerPubkey && typeof conv.partnerPubkey === 'string')
|
||||||
|
.filter((conv) => !isConversationDeleted(conv.partnerPubkey, conv.lastMessageAt, deletedState))
|
||||||
|
const sortedConversations = validConversations.sort(
|
||||||
|
(a, b) => b.lastMessageAt - a.lastMessageAt
|
||||||
|
)
|
||||||
|
setAllConversations(sortedConversations)
|
||||||
|
setConversations(sortedConversations.slice(0, CONVERSATIONS_PER_PAGE))
|
||||||
|
setHasMoreConversations(sortedConversations.length > CONVERSATIONS_PER_PAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAndShowConversations()
|
||||||
|
setIsLoading(false) // Stop spinner, but continue processing in background
|
||||||
|
|
||||||
|
// Sort gift wraps by created_at descending (newest first)
|
||||||
|
const sortedGiftWraps = giftWraps.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
|
// Process gift wraps one by one in the background (progressive loading)
|
||||||
|
for (const giftWrap of sortedGiftWraps) {
|
||||||
|
try {
|
||||||
|
const message = await dmService.decryptMessage(giftWrap, encryption, pubkey)
|
||||||
|
if (message && message.senderPubkey && message.recipientPubkey) {
|
||||||
|
const partnerPubkey =
|
||||||
|
message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
|
||||||
|
|
||||||
|
if (!partnerPubkey || partnerPubkey === '__reaction__') continue
|
||||||
|
|
||||||
|
const existing = conversationMap.get(partnerPubkey)
|
||||||
|
if (!existing || message.createdAt > existing.lastMessageAt) {
|
||||||
|
conversationMap.set(partnerPubkey, {
|
||||||
|
partnerPubkey,
|
||||||
|
lastMessageAt: message.createdAt,
|
||||||
|
lastMessagePreview: message.content.substring(0, 100),
|
||||||
|
unreadCount: 0,
|
||||||
|
preferredEncryption: 'nip17'
|
||||||
|
})
|
||||||
|
// Update UI progressively
|
||||||
|
updateAndShowConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache conversation metadata
|
||||||
|
indexedDb
|
||||||
|
.putDMConversation(
|
||||||
|
pubkey,
|
||||||
|
partnerPubkey,
|
||||||
|
message.createdAt,
|
||||||
|
message.content.substring(0, 100),
|
||||||
|
'nip17'
|
||||||
|
)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip failed decryptions silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final update and cache all conversations
|
||||||
|
updateAndShowConversations()
|
||||||
|
const finalConversations = Array.from(conversationMap.values())
|
||||||
|
Promise.all(
|
||||||
|
finalConversations.map((conv) =>
|
||||||
|
indexedDb.putDMConversation(
|
||||||
|
pubkey,
|
||||||
|
conv.partnerPubkey,
|
||||||
|
conv.lastMessageAt,
|
||||||
|
conv.lastMessagePreview,
|
||||||
|
conv.preferredEncryption
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).catch(() => {})
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load conversations')
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [pubkey, encryption, relayList, deletedState, allConversations])
|
||||||
|
|
||||||
|
const loadMoreConversations = useCallback(async () => {
|
||||||
|
if (!hasMoreConversations) return
|
||||||
|
|
||||||
|
const currentCount = conversations.length
|
||||||
|
const nextBatch = allConversations.slice(currentCount, currentCount + CONVERSATIONS_PER_PAGE)
|
||||||
|
setConversations((prev) => [...prev, ...nextBatch])
|
||||||
|
setHasMoreConversations(currentCount + nextBatch.length < allConversations.length)
|
||||||
|
}, [conversations.length, allConversations, hasMoreConversations])
|
||||||
|
|
||||||
|
const selectConversation = useCallback(
|
||||||
|
(partnerPubkey: string | null) => {
|
||||||
|
// Clear messages immediately to prevent showing old conversation
|
||||||
|
if (partnerPubkey !== currentConversation) {
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
|
setCurrentConversation(partnerPubkey)
|
||||||
|
},
|
||||||
|
[currentConversation]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start a new conversation - marks it as new for UI effects (pulsing settings button)
|
||||||
|
const startConversation = useCallback(
|
||||||
|
(partnerPubkey: string) => {
|
||||||
|
// Check if this is a new conversation (not in existing list)
|
||||||
|
const existingConversation = allConversations.find(
|
||||||
|
(c) => c.partnerPubkey === partnerPubkey
|
||||||
|
)
|
||||||
|
if (!existingConversation) {
|
||||||
|
setIsNewConversation(true)
|
||||||
|
}
|
||||||
|
// Clear messages and select the conversation
|
||||||
|
setMessages([])
|
||||||
|
setCurrentConversation(partnerPubkey)
|
||||||
|
},
|
||||||
|
[allConversations]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearNewConversationFlag = useCallback(() => {
|
||||||
|
setIsNewConversation(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Reload the current conversation by clearing its cached state
|
||||||
|
const reloadConversation = useCallback(() => {
|
||||||
|
if (!currentConversation) return
|
||||||
|
|
||||||
|
// Clear the loaded state and cached messages for this conversation
|
||||||
|
setLoadedConversations((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(currentConversation)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setConversationMessages((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(currentConversation)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
// Clear current messages to trigger a reload
|
||||||
|
setMessages([])
|
||||||
|
}, [currentConversation])
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
async (content: string, customRelayUrls?: string[]) => {
|
||||||
|
if (!pubkey || !encryption || !currentConversation) {
|
||||||
|
throw new Error('Cannot send message: not logged in or no conversation selected')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use custom relays if provided, otherwise fall back to user's write relays
|
||||||
|
const relayUrls = customRelayUrls && customRelayUrls.length > 0
|
||||||
|
? customRelayUrls
|
||||||
|
: (relayList?.write || [])
|
||||||
|
|
||||||
|
// Find existing encryption type for this conversation
|
||||||
|
const conversation = conversations.find((c) => c.partnerPubkey === currentConversation)
|
||||||
|
const existingEncryptionType: TDMEncryptionType | null =
|
||||||
|
conversation?.preferredEncryption ?? null
|
||||||
|
|
||||||
|
// Check for conversation-specific encryption preference
|
||||||
|
const encryptionPref = await indexedDb.getConversationEncryptionPreference(
|
||||||
|
pubkey,
|
||||||
|
currentConversation
|
||||||
|
)
|
||||||
|
|
||||||
|
// Determine the encryption to use based on preference
|
||||||
|
let effectiveEncryption: TDMEncryptionType | null = existingEncryptionType
|
||||||
|
|
||||||
|
if (encryptionPref === 'nip04') {
|
||||||
|
effectiveEncryption = 'nip04'
|
||||||
|
} else if (encryptionPref === 'nip17') {
|
||||||
|
effectiveEncryption = 'nip17'
|
||||||
|
}
|
||||||
|
// 'auto' keeps the existing behavior (match conversation or send both)
|
||||||
|
|
||||||
|
// Send the message
|
||||||
|
const sentEvents = await dmService.sendDM(
|
||||||
|
currentConversation,
|
||||||
|
content,
|
||||||
|
encryption,
|
||||||
|
relayUrls,
|
||||||
|
preferNip44,
|
||||||
|
effectiveEncryption
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create local message for immediate display
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
// Determine the actual encryption type used for the message
|
||||||
|
const usedEncryptionType: TDMEncryptionType =
|
||||||
|
effectiveEncryption || (preferNip44 ? 'nip17' : 'nip04')
|
||||||
|
const newMessage: TDirectMessage = {
|
||||||
|
id: sentEvents[0]?.id || `local-${now}`,
|
||||||
|
senderPubkey: pubkey,
|
||||||
|
recipientPubkey: currentConversation,
|
||||||
|
content,
|
||||||
|
createdAt: now,
|
||||||
|
encryptionType: usedEncryptionType,
|
||||||
|
event: sentEvents[0] || ({} as Event),
|
||||||
|
decryptedContent: content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to messages for this conversation
|
||||||
|
setConversationMessages((prev) => {
|
||||||
|
const existing = prev.get(currentConversation) || []
|
||||||
|
return new Map(prev).set(currentConversation, [...existing, newMessage])
|
||||||
|
})
|
||||||
|
setMessages((prev) => [...prev, newMessage])
|
||||||
|
|
||||||
|
// Update conversation
|
||||||
|
setConversations((prev) => {
|
||||||
|
const existing = prev.find((c) => c.partnerPubkey === currentConversation)
|
||||||
|
if (existing) {
|
||||||
|
return prev.map((c) =>
|
||||||
|
c.partnerPubkey === currentConversation
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
lastMessageAt: now,
|
||||||
|
lastMessagePreview: content.substring(0, 100),
|
||||||
|
preferredEncryption: usedEncryptionType
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
partnerPubkey: currentConversation,
|
||||||
|
lastMessageAt: now,
|
||||||
|
lastMessagePreview: content.substring(0, 100),
|
||||||
|
unreadCount: 0,
|
||||||
|
preferredEncryption: usedEncryptionType
|
||||||
|
},
|
||||||
|
...prev
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[pubkey, encryption, currentConversation, relayList, conversations, preferNip44]
|
||||||
|
)
|
||||||
|
|
||||||
|
const setPreferNip44 = useCallback((prefer: boolean) => {
|
||||||
|
setPreferNip44State(prefer)
|
||||||
|
storage.setPreferNip44(prefer)
|
||||||
|
dispatchSettingsChanged()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Selection mode methods
|
||||||
|
const toggleMessageSelection = useCallback((messageId: string) => {
|
||||||
|
setSelectedMessages((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(messageId)) {
|
||||||
|
next.delete(messageId)
|
||||||
|
// Exit selection mode if nothing selected
|
||||||
|
if (next.size === 0) {
|
||||||
|
setIsSelectionMode(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.add(messageId)
|
||||||
|
// Enter selection mode when first message selected
|
||||||
|
if (!isSelectionMode) {
|
||||||
|
setIsSelectionMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [isSelectionMode])
|
||||||
|
|
||||||
|
const selectAllMessages = useCallback(() => {
|
||||||
|
const allIds = new Set(messages.map((m) => m.id))
|
||||||
|
setSelectedMessages(allIds)
|
||||||
|
setIsSelectionMode(true)
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelectedMessages(new Set())
|
||||||
|
setIsSelectionMode(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Helper to publish deleted state to relays
|
||||||
|
const publishDeletedState = useCallback(
|
||||||
|
async (newState: TDMDeletedState) => {
|
||||||
|
if (!pubkey || !encryption) return
|
||||||
|
|
||||||
|
// Save to IndexedDB
|
||||||
|
await indexedDb.putDeletedMessagesState(pubkey, newState)
|
||||||
|
|
||||||
|
// Publish to relays
|
||||||
|
const relayUrls = relayList?.write || BIG_RELAY_URLS
|
||||||
|
const draftEvent = createDeletedMessagesDraftEvent(newState)
|
||||||
|
const signedEvent = await encryption.signEvent(draftEvent)
|
||||||
|
await client.publishEvent(relayUrls, signedEvent)
|
||||||
|
},
|
||||||
|
[pubkey, encryption, relayList]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete selected messages (soft delete only - no kind 5, so undelete always works)
|
||||||
|
const deleteSelectedMessages = useCallback(async () => {
|
||||||
|
if (!pubkey || selectedMessages.size === 0) return
|
||||||
|
|
||||||
|
const messageIds = Array.from(selectedMessages)
|
||||||
|
|
||||||
|
// Update deleted state
|
||||||
|
const newDeletedState: TDMDeletedState = {
|
||||||
|
deletedIds: [...(deletedState?.deletedIds || []), ...messageIds],
|
||||||
|
deletedRanges: deletedState?.deletedRanges || {}
|
||||||
|
}
|
||||||
|
setDeletedState(newDeletedState)
|
||||||
|
|
||||||
|
// Remove from UI
|
||||||
|
setMessages((prev) => prev.filter((m) => !selectedMessages.has(m.id)))
|
||||||
|
if (currentConversation) {
|
||||||
|
setConversationMessages((prev) => {
|
||||||
|
const existing = prev.get(currentConversation) || []
|
||||||
|
return new Map(prev).set(
|
||||||
|
currentConversation,
|
||||||
|
existing.filter((m) => !selectedMessages.has(m.id))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
setSelectedMessages(new Set())
|
||||||
|
setIsSelectionMode(false)
|
||||||
|
|
||||||
|
// Publish to relays
|
||||||
|
await publishDeletedState(newDeletedState)
|
||||||
|
}, [pubkey, selectedMessages, deletedState, currentConversation, publishDeletedState])
|
||||||
|
|
||||||
|
// Delete all messages in current conversation (timestamp range)
|
||||||
|
const deleteAllInConversation = useCallback(async () => {
|
||||||
|
if (!pubkey || !currentConversation) return
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const newRange = { start: 0, end: now }
|
||||||
|
|
||||||
|
// Update deleted state with new range
|
||||||
|
const newDeletedState: TDMDeletedState = {
|
||||||
|
deletedIds: deletedState?.deletedIds || [],
|
||||||
|
deletedRanges: {
|
||||||
|
...(deletedState?.deletedRanges || {}),
|
||||||
|
[currentConversation]: [
|
||||||
|
...(deletedState?.deletedRanges[currentConversation] || []),
|
||||||
|
newRange
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDeletedState(newDeletedState)
|
||||||
|
|
||||||
|
// Clear messages from UI
|
||||||
|
setMessages([])
|
||||||
|
setConversationMessages((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(currentConversation)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove conversation from list
|
||||||
|
setConversations((prev) => prev.filter((c) => c.partnerPubkey !== currentConversation))
|
||||||
|
setAllConversations((prev) => prev.filter((c) => c.partnerPubkey !== currentConversation))
|
||||||
|
|
||||||
|
// Clear selection and close conversation
|
||||||
|
setSelectedMessages(new Set())
|
||||||
|
setIsSelectionMode(false)
|
||||||
|
setCurrentConversation(null)
|
||||||
|
|
||||||
|
// Publish to relays
|
||||||
|
await publishDeletedState(newDeletedState)
|
||||||
|
}, [pubkey, currentConversation, deletedState, publishDeletedState])
|
||||||
|
|
||||||
|
// Undelete all messages in current conversation (remove delete markers)
|
||||||
|
const undeleteAllInConversation = useCallback(async () => {
|
||||||
|
if (!pubkey || !currentConversation) return
|
||||||
|
|
||||||
|
// Remove all delete markers for this conversation
|
||||||
|
const newDeletedState: TDMDeletedState = {
|
||||||
|
deletedIds: deletedState?.deletedIds || [],
|
||||||
|
deletedRanges: {
|
||||||
|
...(deletedState?.deletedRanges || {}),
|
||||||
|
[currentConversation]: [] // Clear all ranges for this conversation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDeletedState(newDeletedState)
|
||||||
|
|
||||||
|
// Clear cached messages to force reload
|
||||||
|
setConversationMessages((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(currentConversation)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setLoadedConversations((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(currentConversation)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// Publish to relays
|
||||||
|
await publishDeletedState(newDeletedState)
|
||||||
|
|
||||||
|
// Trigger a background refresh of conversations
|
||||||
|
await backgroundRefreshConversations()
|
||||||
|
}, [pubkey, currentConversation, deletedState, publishDeletedState, backgroundRefreshConversations])
|
||||||
|
|
||||||
|
// Filter out deleted conversations from the list
|
||||||
|
const filteredConversations = useMemo(() => {
|
||||||
|
if (!deletedState) return conversations
|
||||||
|
return conversations.filter(
|
||||||
|
(c) => !isConversationDeleted(c.partnerPubkey, c.lastMessageAt, deletedState)
|
||||||
|
)
|
||||||
|
}, [conversations, deletedState])
|
||||||
|
|
||||||
|
// Calculate total unread count across all conversations
|
||||||
|
const totalUnreadCount = useMemo(() => {
|
||||||
|
return filteredConversations.reduce((sum, c) => sum + c.unreadCount, 0)
|
||||||
|
}, [filteredConversations])
|
||||||
|
|
||||||
|
// Check if there are new messages since last seen
|
||||||
|
const newestMessageTimestamp = useMemo(() => {
|
||||||
|
const fromConversations = filteredConversations.length === 0
|
||||||
|
? 0
|
||||||
|
: Math.max(...filteredConversations.map((c) => c.lastMessageAt))
|
||||||
|
// Also consider real-time incoming messages
|
||||||
|
return Math.max(fromConversations, newestIncomingTimestamp)
|
||||||
|
}, [filteredConversations, newestIncomingTimestamp])
|
||||||
|
|
||||||
|
const hasNewMessages = newestMessageTimestamp > lastSeenTimestamp
|
||||||
|
|
||||||
|
// Mark inbox as seen (update last seen timestamp)
|
||||||
|
const markInboxAsSeen = useCallback(() => {
|
||||||
|
if (!pubkey || newestMessageTimestamp === 0) return
|
||||||
|
setLastSeenTimestamp(newestMessageTimestamp)
|
||||||
|
storage.setDMLastSeenTimestamp(pubkey, newestMessageTimestamp)
|
||||||
|
// Reset incoming timestamp so indicator clears
|
||||||
|
setNewestIncomingTimestamp(0)
|
||||||
|
}, [pubkey, newestMessageTimestamp])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DMContext.Provider
|
||||||
|
value={{
|
||||||
|
conversations: filteredConversations,
|
||||||
|
currentConversation,
|
||||||
|
messages,
|
||||||
|
isLoading,
|
||||||
|
isLoadingConversation,
|
||||||
|
error,
|
||||||
|
selectConversation,
|
||||||
|
startConversation,
|
||||||
|
sendMessage,
|
||||||
|
refreshConversations,
|
||||||
|
reloadConversation,
|
||||||
|
loadMoreConversations,
|
||||||
|
hasMoreConversations,
|
||||||
|
preferNip44,
|
||||||
|
setPreferNip44,
|
||||||
|
isNewConversation,
|
||||||
|
clearNewConversationFlag,
|
||||||
|
// Unread tracking
|
||||||
|
totalUnreadCount,
|
||||||
|
hasNewMessages,
|
||||||
|
markInboxAsSeen,
|
||||||
|
// Selection mode
|
||||||
|
selectedMessages,
|
||||||
|
isSelectionMode,
|
||||||
|
toggleMessageSelection,
|
||||||
|
selectAllMessages,
|
||||||
|
clearSelection,
|
||||||
|
// Deletion
|
||||||
|
deleteSelectedMessages,
|
||||||
|
deleteAllInConversation,
|
||||||
|
undeleteAllInConversation
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DMContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ const NIP46_METHOD = {
|
|||||||
SIGN_EVENT: 'sign_event',
|
SIGN_EVENT: 'sign_event',
|
||||||
NIP04_ENCRYPT: 'nip04_encrypt',
|
NIP04_ENCRYPT: 'nip04_encrypt',
|
||||||
NIP04_DECRYPT: 'nip04_decrypt',
|
NIP04_DECRYPT: 'nip04_decrypt',
|
||||||
|
NIP44_ENCRYPT: 'nip44_encrypt',
|
||||||
|
NIP44_DECRYPT: 'nip44_decrypt',
|
||||||
PING: 'ping'
|
PING: 'ping'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -59,12 +61,13 @@ function generateRequestId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>).
|
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>&cat=<token>).
|
||||||
*/
|
*/
|
||||||
export function parseBunkerUrl(url: string): {
|
export function parseBunkerUrl(url: string): {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
relays: string[]
|
relays: string[]
|
||||||
secret?: string
|
secret?: string
|
||||||
|
catToken?: string
|
||||||
} {
|
} {
|
||||||
if (!url.startsWith('bunker://')) {
|
if (!url.startsWith('bunker://')) {
|
||||||
throw new Error('Invalid bunker URL: must start with bunker://')
|
throw new Error('Invalid bunker URL: must start with bunker://')
|
||||||
@@ -80,6 +83,7 @@ export function parseBunkerUrl(url: string): {
|
|||||||
const params = new URLSearchParams(queryPart || '')
|
const params = new URLSearchParams(queryPart || '')
|
||||||
const relays = params.getAll('relay')
|
const relays = params.getAll('relay')
|
||||||
const secret = params.get('secret') || undefined
|
const secret = params.get('secret') || undefined
|
||||||
|
const catToken = params.get('cat') || undefined
|
||||||
|
|
||||||
if (relays.length === 0) {
|
if (relays.length === 0) {
|
||||||
throw new Error('Invalid bunker URL: no relay specified')
|
throw new Error('Invalid bunker URL: no relay specified')
|
||||||
@@ -88,7 +92,8 @@ export function parseBunkerUrl(url: string): {
|
|||||||
return {
|
return {
|
||||||
pubkey: pubkeyPart,
|
pubkey: pubkeyPart,
|
||||||
relays,
|
relays,
|
||||||
secret
|
secret,
|
||||||
|
catToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,19 +180,28 @@ export class BunkerSigner implements ISigner {
|
|||||||
// Whether we're waiting for signer to connect (reverse flow)
|
// Whether we're waiting for signer to connect (reverse flow)
|
||||||
private awaitingConnection = false
|
private awaitingConnection = false
|
||||||
private connectionResolve: ((pubkey: string) => void) | null = null
|
private connectionResolve: ((pubkey: string) => void) | null = null
|
||||||
private connectionReject: ((error: Error) => void) | null = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a BunkerSigner.
|
* Create a BunkerSigner.
|
||||||
* @param bunkerPubkey - The bunker's public key (hex)
|
* @param bunkerPubkey - The bunker's public key (hex)
|
||||||
* @param relayUrls - Relay URLs to connect to
|
* @param relayUrls - Relay URLs to connect to
|
||||||
* @param connectionSecret - Optional connection secret for initial handshake
|
* @param connectionSecret - Optional connection secret for initial handshake
|
||||||
|
* @param catToken - Optional CAT token (encoded string) for authorization
|
||||||
*/
|
*/
|
||||||
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string) {
|
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string, catToken?: string) {
|
||||||
this.bunkerPubkey = bunkerPubkey
|
this.bunkerPubkey = bunkerPubkey
|
||||||
this.relayUrls = relayUrls
|
this.relayUrls = relayUrls
|
||||||
this.connectionSecret = connectionSecret
|
this.connectionSecret = connectionSecret
|
||||||
|
|
||||||
|
// Decode CAT token if provided
|
||||||
|
if (catToken) {
|
||||||
|
try {
|
||||||
|
this.token = cashuTokenService.decodeToken(catToken)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to decode CAT token from URL:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate local ephemeral keypair for NIP-46 communication
|
// Generate local ephemeral keypair for NIP-46 communication
|
||||||
this.localPrivkey = secp256k1.utils.randomPrivateKey()
|
this.localPrivkey = secp256k1.utils.randomPrivateKey()
|
||||||
this.localPubkey = nGetPublicKey(this.localPrivkey)
|
this.localPubkey = nGetPublicKey(this.localPrivkey)
|
||||||
@@ -230,8 +244,6 @@ export class BunkerSigner implements ISigner {
|
|||||||
signer.awaitingConnection = false
|
signer.awaitingConnection = false
|
||||||
resolve(signer)
|
resolve(signer)
|
||||||
}
|
}
|
||||||
signer.connectionReject = reject
|
|
||||||
|
|
||||||
// Set timeout
|
// Set timeout
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (signer.awaitingConnection) {
|
if (signer.awaitingConnection) {
|
||||||
@@ -407,7 +419,7 @@ export class BunkerSigner implements ISigner {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const mintInfo = await infoResponse.json()
|
await infoResponse.json() // Validate JSON response
|
||||||
console.log(`Relay ${relayUrl} requires Cashu token, acquiring...`)
|
console.log(`Relay ${relayUrl} requires Cashu token, acquiring...`)
|
||||||
|
|
||||||
// Configure the mint
|
// Configure the mint
|
||||||
@@ -630,12 +642,16 @@ export class BunkerSigner implements ISigner {
|
|||||||
// Encrypt with NIP-04 to the bunker's pubkey
|
// Encrypt with NIP-04 to the bunker's pubkey
|
||||||
const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
|
const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
|
||||||
|
|
||||||
// Create NIP-46 request event
|
// Create NIP-46 request event with optional CAT tag
|
||||||
|
const tags: string[][] = [['p', this.bunkerPubkey]]
|
||||||
|
if (this.token) {
|
||||||
|
tags.push(['cat', cashuTokenService.encodeToken(this.token)])
|
||||||
|
}
|
||||||
const draftEvent: TDraftEvent = {
|
const draftEvent: TDraftEvent = {
|
||||||
kind: 24133,
|
kind: 24133,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
content: encrypted,
|
content: encrypted,
|
||||||
tags: [['p', this.bunkerPubkey]]
|
tags
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
|
const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
|
||||||
@@ -711,6 +727,20 @@ export class BunkerSigner implements ISigner {
|
|||||||
return this.sendRequest(NIP46_METHOD.NIP04_DECRYPT, [pubkey, cipherText])
|
return this.sendRequest(NIP46_METHOD.NIP04_DECRYPT, [pubkey, cipherText])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a message with NIP-44 via the bunker.
|
||||||
|
*/
|
||||||
|
async nip44Encrypt(pubkey: string, plainText: string): Promise<string> {
|
||||||
|
return this.sendRequest(NIP46_METHOD.NIP44_ENCRYPT, [pubkey, plainText])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a message with NIP-44 via the bunker.
|
||||||
|
*/
|
||||||
|
async nip44Decrypt(pubkey: string, cipherText: string): Promise<string> {
|
||||||
|
return this.sendRequest(NIP46_METHOD.NIP44_DECRYPT, [pubkey, cipherText])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if connected to the bunker.
|
* Check if connected to the bunker.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ type TNostrContext = {
|
|||||||
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
|
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
|
||||||
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
|
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
|
||||||
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
||||||
|
nip44Encrypt: (pubkey: string, plainText: string) => Promise<string>
|
||||||
|
nip44Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
||||||
|
hasNip44Support: boolean
|
||||||
startLogin: () => void
|
startLogin: () => void
|
||||||
checkLogin: <T>(cb?: () => T) => Promise<T | void>
|
checkLogin: <T>(cb?: () => T) => Promise<T | void>
|
||||||
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
|
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
|
||||||
@@ -528,8 +531,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const bunkerLogin = async (bunkerUrl: string) => {
|
const bunkerLogin = async (bunkerUrl: string) => {
|
||||||
try {
|
try {
|
||||||
const { pubkey: bunkerPubkey, relays, secret } = parseBunkerUrl(bunkerUrl)
|
const { pubkey: bunkerPubkey, relays, secret, catToken } = parseBunkerUrl(bunkerUrl)
|
||||||
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret)
|
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret, catToken)
|
||||||
await bunkerSigner.init()
|
await bunkerSigner.init()
|
||||||
const pubkey = await bunkerSigner.getPublicKey()
|
const pubkey = await bunkerSigner.getPublicKey()
|
||||||
return login(bunkerSigner, {
|
return login(bunkerSigner, {
|
||||||
@@ -537,7 +540,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
signerType: 'bunker',
|
signerType: 'bunker',
|
||||||
bunkerPubkey,
|
bunkerPubkey,
|
||||||
bunkerRelays: relays,
|
bunkerRelays: relays,
|
||||||
bunkerSecret: secret
|
bunkerSecret: secret,
|
||||||
|
bunkerCatToken: catToken
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
|
toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
|
||||||
@@ -614,7 +618,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const bunkerSigner = new BunkerSigner(
|
const bunkerSigner = new BunkerSigner(
|
||||||
account.bunkerPubkey,
|
account.bunkerPubkey,
|
||||||
account.bunkerRelays,
|
account.bunkerRelays,
|
||||||
account.bunkerSecret
|
account.bunkerSecret,
|
||||||
|
account.bunkerCatToken
|
||||||
)
|
)
|
||||||
await bunkerSigner.init()
|
await bunkerSigner.init()
|
||||||
return login(bunkerSigner, account)
|
return login(bunkerSigner, account)
|
||||||
@@ -721,13 +726,41 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nip04Encrypt = async (pubkey: string, plainText: string) => {
|
const nip04Encrypt = async (pubkey: string, plainText: string) => {
|
||||||
return signer?.nip04Encrypt(pubkey, plainText) ?? ''
|
if (!signer) {
|
||||||
|
throw new Error('No signer available for NIP-04 encryption')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await signer.nip04Encrypt(pubkey, plainText)
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('NIP-04 encryption returned empty result')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
console.error('NIP-04 encryption failed:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nip04Decrypt = async (pubkey: string, cipherText: string) => {
|
const nip04Decrypt = async (pubkey: string, cipherText: string) => {
|
||||||
return signer?.nip04Decrypt(pubkey, cipherText) ?? ''
|
return signer?.nip04Decrypt(pubkey, cipherText) ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nip44Encrypt = async (pubkey: string, plainText: string) => {
|
||||||
|
if (!signer?.nip44Encrypt) {
|
||||||
|
throw new Error('NIP-44 encryption not supported by this signer')
|
||||||
|
}
|
||||||
|
return signer.nip44Encrypt(pubkey, plainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nip44Decrypt = async (pubkey: string, cipherText: string) => {
|
||||||
|
if (!signer?.nip44Decrypt) {
|
||||||
|
throw new Error('NIP-44 decryption not supported by this signer')
|
||||||
|
}
|
||||||
|
return signer.nip44Decrypt(pubkey, cipherText)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNip44Support = !!signer?.nip44Encrypt && !!signer?.nip44Decrypt
|
||||||
|
|
||||||
const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
|
const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
|
||||||
if (signer) {
|
if (signer) {
|
||||||
return cb && cb()
|
return cb && cb()
|
||||||
@@ -855,6 +888,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
signHttpAuth,
|
signHttpAuth,
|
||||||
nip04Encrypt,
|
nip04Encrypt,
|
||||||
nip04Decrypt,
|
nip04Decrypt,
|
||||||
|
nip44Encrypt,
|
||||||
|
nip44Decrypt,
|
||||||
|
hasNip44Support,
|
||||||
startLogin: () => setOpenLoginDialog(true),
|
startLogin: () => setOpenLoginDialog(true),
|
||||||
checkLogin,
|
checkLogin,
|
||||||
signEvent,
|
signEvent,
|
||||||
|
|||||||
@@ -57,4 +57,24 @@ export class Nip07Signer implements ISigner {
|
|||||||
}
|
}
|
||||||
return await this.signer.nip04.decrypt(pubkey, cipherText)
|
return await this.signer.nip04.decrypt(pubkey, cipherText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async nip44Encrypt(pubkey: string, plainText: string) {
|
||||||
|
if (!this.signer) {
|
||||||
|
throw new Error('Should call init() first')
|
||||||
|
}
|
||||||
|
if (!this.signer.nip44?.encrypt) {
|
||||||
|
throw new Error('The extension you are using does not support nip44 encryption')
|
||||||
|
}
|
||||||
|
return await this.signer.nip44.encrypt(pubkey, plainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip44Decrypt(pubkey: string, cipherText: string) {
|
||||||
|
if (!this.signer) {
|
||||||
|
throw new Error('Should call init() first')
|
||||||
|
}
|
||||||
|
if (!this.signer.nip44?.decrypt) {
|
||||||
|
throw new Error('The extension you are using does not support nip44 decryption')
|
||||||
|
}
|
||||||
|
return await this.signer.nip44.decrypt(pubkey, cipherText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ISigner, TDraftEvent } from '@/types'
|
|||||||
import * as utils from '@noble/curves/abstract/utils'
|
import * as utils from '@noble/curves/abstract/utils'
|
||||||
import { bech32 } from '@scure/base'
|
import { bech32 } from '@scure/base'
|
||||||
import { finalizeEvent, getPublicKey as nGetPublicKey, nip04 } from 'nostr-tools'
|
import { finalizeEvent, getPublicKey as nGetPublicKey, nip04 } from 'nostr-tools'
|
||||||
|
import * as nip44 from 'nostr-tools/nip44'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert nsec (bech32) to hex string
|
* Convert nsec (bech32) to hex string
|
||||||
@@ -89,4 +90,20 @@ export class NsecSigner implements ISigner {
|
|||||||
}
|
}
|
||||||
return nip04.decrypt(this.privkey, pubkey, cipherText)
|
return nip04.decrypt(this.privkey, pubkey, cipherText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async nip44Encrypt(pubkey: string, plainText: string) {
|
||||||
|
if (!this.privkey) {
|
||||||
|
throw new Error('Not logged in')
|
||||||
|
}
|
||||||
|
const conversationKey = nip44.v2.utils.getConversationKey(this.privkey, pubkey)
|
||||||
|
return nip44.v2.encrypt(plainText, conversationKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip44Decrypt(pubkey: string, cipherText: string) {
|
||||||
|
if (!this.privkey) {
|
||||||
|
throw new Error('Not logged in')
|
||||||
|
}
|
||||||
|
const conversationKey = nip44.v2.utils.getConversationKey(this.privkey, pubkey)
|
||||||
|
return nip44.v2.decrypt(cipherText, conversationKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BookmarkPage from '@/pages/primary/BookmarkPage'
|
import BookmarkPage from '@/pages/primary/BookmarkPage'
|
||||||
|
import InboxPage from '@/pages/primary/InboxPage'
|
||||||
import MePage from '@/pages/primary/MePage'
|
import MePage from '@/pages/primary/MePage'
|
||||||
import NoteListPage from '@/pages/primary/NoteListPage'
|
import NoteListPage from '@/pages/primary/NoteListPage'
|
||||||
import NotificationListPage from '@/pages/primary/NotificationListPage'
|
import NotificationListPage from '@/pages/primary/NotificationListPage'
|
||||||
@@ -16,6 +17,7 @@ type RouteConfig = {
|
|||||||
|
|
||||||
const PRIMARY_ROUTE_CONFIGS: RouteConfig[] = [
|
const PRIMARY_ROUTE_CONFIGS: RouteConfig[] = [
|
||||||
{ key: 'home', component: NoteListPage },
|
{ key: 'home', component: NoteListPage },
|
||||||
|
{ key: 'inbox', component: InboxPage },
|
||||||
{ key: 'notifications', component: NotificationListPage },
|
{ key: 'notifications', component: NotificationListPage },
|
||||||
{ key: 'me', component: MePage },
|
{ key: 'me', component: MePage },
|
||||||
{ key: 'profile', component: ProfilePage },
|
{ key: 'profile', component: ProfilePage },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppearanceSettingsPage from '@/pages/secondary/AppearanceSettingsPage'
|
import AppearanceSettingsPage from '@/pages/secondary/AppearanceSettingsPage'
|
||||||
import BookmarkPage from '@/pages/secondary/BookmarkPage'
|
import BookmarkPage from '@/pages/secondary/BookmarkPage'
|
||||||
|
import DMConversationPage from '@/pages/secondary/DMConversationPage'
|
||||||
import EmojiPackSettingsPage from '@/pages/secondary/EmojiPackSettingsPage'
|
import EmojiPackSettingsPage from '@/pages/secondary/EmojiPackSettingsPage'
|
||||||
import ExternalContentPage from '@/pages/secondary/ExternalContentPage'
|
import ExternalContentPage from '@/pages/secondary/ExternalContentPage'
|
||||||
import FollowingListPage from '@/pages/secondary/FollowingListPage'
|
import FollowingListPage from '@/pages/secondary/FollowingListPage'
|
||||||
@@ -53,6 +54,7 @@ const SECONDARY_ROUTE_CONFIGS = [
|
|||||||
{ path: '/mutes', element: <MuteListPage /> },
|
{ path: '/mutes', element: <MuteListPage /> },
|
||||||
{ path: '/rizful', element: <RizfulPage /> },
|
{ path: '/rizful', element: <RizfulPage /> },
|
||||||
{ path: '/bookmarks', element: <BookmarkPage /> },
|
{ path: '/bookmarks', element: <BookmarkPage /> },
|
||||||
|
{ path: '/dm/:pubkey', element: <DMConversationPage /> },
|
||||||
{ path: '/follow-packs/:id', element: <FollowPackPage /> },
|
{ path: '/follow-packs/:id', element: <FollowPackPage /> },
|
||||||
{ path: '/user-aggregation/:feedId/:npub', element: <UserAggregationDetailPage /> }
|
{ path: '/user-aggregation/:feedId/:npub', element: <UserAggregationDetailPage /> }
|
||||||
]
|
]
|
||||||
|
|||||||
993
src/services/dm.service.ts
Normal file
993
src/services/dm.service.ts
Normal file
@@ -0,0 +1,993 @@
|
|||||||
|
/**
|
||||||
|
* DM Service - Direct Message handling with NIP-04 and NIP-17 encryption support
|
||||||
|
*
|
||||||
|
* NIP-04: Kind 4 encrypted direct messages (legacy)
|
||||||
|
* NIP-17: Kind 14 private direct messages with NIP-59 gift wrapping (modern)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ARCHIVE_RELAY_URL, BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||||
|
import { TConversation, TDirectMessage, TDMDeletedState, TDMEncryptionType, TDraftEvent } from '@/types'
|
||||||
|
import { Event, kinds, VerifiedEvent } from 'nostr-tools'
|
||||||
|
import client from './client.service'
|
||||||
|
import indexedDb from './indexed-db.service'
|
||||||
|
|
||||||
|
// In-memory plaintext cache for fast access (avoids async IndexedDB lookups on re-render)
|
||||||
|
const plaintextCache = new Map<string, string>()
|
||||||
|
const MAX_CACHE_SIZE = 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plaintext from in-memory cache
|
||||||
|
*/
|
||||||
|
export function getCachedPlaintext(eventId: string): string | undefined {
|
||||||
|
return plaintextCache.get(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set plaintext in in-memory cache (with LRU eviction)
|
||||||
|
*/
|
||||||
|
export function setCachedPlaintext(eventId: string, plaintext: string): void {
|
||||||
|
// Simple LRU: if cache is full, delete oldest entries
|
||||||
|
if (plaintextCache.size >= MAX_CACHE_SIZE) {
|
||||||
|
const keysToDelete = Array.from(plaintextCache.keys()).slice(0, 100)
|
||||||
|
keysToDelete.forEach(k => plaintextCache.delete(k))
|
||||||
|
}
|
||||||
|
plaintextCache.set(eventId, plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the plaintext cache (e.g., on logout)
|
||||||
|
*/
|
||||||
|
export function clearPlaintextCache(): void {
|
||||||
|
plaintextCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt messages in batches to avoid blocking the UI
|
||||||
|
* Yields control back to the event loop between batches
|
||||||
|
*/
|
||||||
|
export async function decryptMessagesInBatches(
|
||||||
|
events: Event[],
|
||||||
|
encryption: IDMEncryption,
|
||||||
|
myPubkey: string,
|
||||||
|
batchSize: number = 10,
|
||||||
|
onBatchComplete?: (messages: TDirectMessage[], progress: number) => void
|
||||||
|
): Promise<TDirectMessage[]> {
|
||||||
|
const allMessages: TDirectMessage[] = []
|
||||||
|
const total = events.length
|
||||||
|
|
||||||
|
for (let i = 0; i < events.length; i += batchSize) {
|
||||||
|
const batch = events.slice(i, i + batchSize)
|
||||||
|
|
||||||
|
// Process batch
|
||||||
|
const batchResults = await Promise.all(
|
||||||
|
batch.map((event) => dmService.decryptMessage(event, encryption, myPubkey))
|
||||||
|
)
|
||||||
|
|
||||||
|
const validMessages = batchResults.filter((m): m is TDirectMessage => m !== null)
|
||||||
|
allMessages.push(...validMessages)
|
||||||
|
|
||||||
|
// Report progress
|
||||||
|
const progress = Math.min((i + batchSize) / total, 1)
|
||||||
|
onBatchComplete?.(validMessages, progress)
|
||||||
|
|
||||||
|
// Yield to event loop between batches (prevents UI blocking)
|
||||||
|
if (i + batchSize < events.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and publish a kind 5 delete request for own messages
|
||||||
|
* This requests relays to delete the original event
|
||||||
|
*/
|
||||||
|
export async function publishDeleteRequest(
|
||||||
|
eventIds: string[],
|
||||||
|
eventKind: number,
|
||||||
|
encryption: IDMEncryption,
|
||||||
|
relayUrls: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (eventIds.length === 0) return
|
||||||
|
|
||||||
|
const draftEvent: TDraftEvent = {
|
||||||
|
kind: kinds.EventDeletion, // 5
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: 'Deleted by sender',
|
||||||
|
tags: [
|
||||||
|
['k', eventKind.toString()],
|
||||||
|
...eventIds.map((id) => ['e', id])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedEvent = await encryption.signEvent(draftEvent)
|
||||||
|
await client.publishEvent(relayUrls, signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encryption methods interface for DM operations
|
||||||
|
*/
|
||||||
|
export interface IDMEncryption {
|
||||||
|
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
|
||||||
|
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
||||||
|
nip44Encrypt?: (pubkey: string, plainText: string) => Promise<string>
|
||||||
|
nip44Decrypt?: (pubkey: string, cipherText: string) => Promise<string>
|
||||||
|
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
|
||||||
|
getPublicKey: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP-04 uses kind 4
|
||||||
|
const KIND_ENCRYPTED_DM = kinds.EncryptedDirectMessage // 4
|
||||||
|
|
||||||
|
// NIP-17 uses kind 14 for chat messages, wrapped in gift wraps
|
||||||
|
const KIND_PRIVATE_DM = ExtendedKind.PRIVATE_DM // 14
|
||||||
|
const KIND_SEAL = ExtendedKind.SEAL // 13
|
||||||
|
const KIND_GIFT_WRAP = ExtendedKind.GIFT_WRAP // 1059
|
||||||
|
const KIND_REACTION = kinds.Reaction // 7
|
||||||
|
|
||||||
|
// 15 second timeout for DM fetches - if relays are dead, don't wait forever
|
||||||
|
const DM_FETCH_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a promise with a timeout that returns empty array on timeout or error
|
||||||
|
*/
|
||||||
|
function withTimeout<T>(promise: Promise<T[]>, ms: number): Promise<T[]> {
|
||||||
|
const timeoutPromise = new Promise<T[]>((resolve) => {
|
||||||
|
setTimeout(() => resolve([]), ms)
|
||||||
|
})
|
||||||
|
const safePromise = promise.catch(() => [] as T[])
|
||||||
|
return Promise.race([safePromise, timeoutPromise])
|
||||||
|
}
|
||||||
|
|
||||||
|
class DMService {
|
||||||
|
/**
|
||||||
|
* Fetch all DM events for a user from relays
|
||||||
|
*/
|
||||||
|
async fetchDMEvents(pubkey: string, relayUrls: string[], limit = 500): Promise<Event[]> {
|
||||||
|
const allRelays = [...new Set([...relayUrls, ...BIG_RELAY_URLS])]
|
||||||
|
|
||||||
|
// Fetch NIP-04 DMs (kind 4) and NIP-17 gift wraps in parallel
|
||||||
|
const nip04Filter = {
|
||||||
|
kinds: [KIND_ENCRYPTED_DM],
|
||||||
|
limit
|
||||||
|
}
|
||||||
|
|
||||||
|
const [incomingNip04, outgoingNip04, giftWraps] = await Promise.all([
|
||||||
|
// Fetch messages sent TO the user
|
||||||
|
withTimeout(
|
||||||
|
client.fetchEvents(allRelays, {
|
||||||
|
...nip04Filter,
|
||||||
|
'#p': [pubkey]
|
||||||
|
}),
|
||||||
|
DM_FETCH_TIMEOUT_MS
|
||||||
|
),
|
||||||
|
// Fetch messages sent BY the user
|
||||||
|
withTimeout(
|
||||||
|
client.fetchEvents(allRelays, {
|
||||||
|
...nip04Filter,
|
||||||
|
authors: [pubkey]
|
||||||
|
}),
|
||||||
|
DM_FETCH_TIMEOUT_MS
|
||||||
|
),
|
||||||
|
// Fetch NIP-17 gift wraps (kind 1059) - these are addressed to the user
|
||||||
|
withTimeout(
|
||||||
|
client.fetchEvents(allRelays, {
|
||||||
|
kinds: [KIND_GIFT_WRAP],
|
||||||
|
'#p': [pubkey],
|
||||||
|
limit
|
||||||
|
}),
|
||||||
|
DM_FETCH_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
// Combine all events
|
||||||
|
const allEvents = [...incomingNip04, ...outgoingNip04, ...giftWraps]
|
||||||
|
|
||||||
|
// Store in IndexedDB for caching
|
||||||
|
await Promise.all(allEvents.map((event) => indexedDb.putDMEvent(event)))
|
||||||
|
|
||||||
|
return allEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recent DM events (limited) for building conversation list
|
||||||
|
* Returns only most recent events to quickly show conversations
|
||||||
|
*/
|
||||||
|
async fetchRecentDMEvents(pubkey: string, relayUrls: string[]): Promise<Event[]> {
|
||||||
|
// Fetch with smaller limit for faster initial load
|
||||||
|
return this.fetchDMEvents(pubkey, relayUrls, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all DM events for a specific conversation partner
|
||||||
|
*/
|
||||||
|
async fetchConversationEvents(
|
||||||
|
pubkey: string,
|
||||||
|
partnerPubkey: string,
|
||||||
|
relayUrls: string[]
|
||||||
|
): Promise<Event[]> {
|
||||||
|
const allRelays = [...new Set([...relayUrls, ...BIG_RELAY_URLS])]
|
||||||
|
|
||||||
|
// Get partner's inbox relays for better NIP-17 discovery
|
||||||
|
const partnerInboxRelays = await this.fetchPartnerInboxRelays(partnerPubkey)
|
||||||
|
const inboxRelays = [...new Set([...relayUrls, ...partnerInboxRelays, ...BIG_RELAY_URLS])]
|
||||||
|
|
||||||
|
// Fetch NIP-04 messages between user and partner (with timeout)
|
||||||
|
const [incomingNip04, outgoingNip04, giftWraps] = await Promise.all([
|
||||||
|
// Messages FROM partner TO user
|
||||||
|
withTimeout(
|
||||||
|
client.fetchEvents(allRelays, {
|
||||||
|
kinds: [KIND_ENCRYPTED_DM],
|
||||||
|
authors: [partnerPubkey],
|
||||||
|
'#p': [pubkey],
|
||||||
|
limit: 500
|
||||||
|
}),
|
||||||
|
DM_FETCH_TIMEOUT_MS
|
||||||
|
),
|
||||||
|
// Messages FROM user TO partner
|
||||||
|
withTimeout(
|
||||||
|
client.fetchEvents(allRelays, {
|
||||||
|
kinds: [KIND_ENCRYPTED_DM],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#p': [partnerPubkey],
|
||||||
|
limit: 500
|
||||||
|
}),
|
||||||
|
DM_FETCH_TIMEOUT_MS
|
||||||
|
),
|
||||||
|
// Gift wraps addressed to user - check both regular relays and inbox relays
|
||||||
|
withTimeout(
|
||||||
|
client.fetchEvents(inboxRelays, {
|
||||||
|
kinds: [KIND_GIFT_WRAP],
|
||||||
|
'#p': [pubkey],
|
||||||
|
limit: 500
|
||||||
|
}),
|
||||||
|
DM_FETCH_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
const allEvents = [...incomingNip04, ...outgoingNip04, ...giftWraps]
|
||||||
|
|
||||||
|
// Store in IndexedDB for caching
|
||||||
|
await Promise.all(allEvents.map((event) => indexedDb.putDMEvent(event)))
|
||||||
|
|
||||||
|
return allEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a DM event and return a TDirectMessage
|
||||||
|
*/
|
||||||
|
async decryptMessage(
|
||||||
|
event: Event,
|
||||||
|
encryption: IDMEncryption,
|
||||||
|
myPubkey: string
|
||||||
|
): Promise<TDirectMessage | null> {
|
||||||
|
try {
|
||||||
|
if (event.kind === KIND_ENCRYPTED_DM) {
|
||||||
|
// NIP-04 decryption - check in-memory cache first (fastest)
|
||||||
|
const memCached = getCachedPlaintext(event.id)
|
||||||
|
if (memCached) {
|
||||||
|
return this.buildDirectMessage(event, memCached, myPubkey, 'nip04')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IndexedDB cache (slower but persistent)
|
||||||
|
const dbCached = await indexedDb.getDecryptedContent(event.id)
|
||||||
|
if (dbCached) {
|
||||||
|
// Populate in-memory cache for next access
|
||||||
|
setCachedPlaintext(event.id, dbCached)
|
||||||
|
return this.buildDirectMessage(event, dbCached, myPubkey, 'nip04')
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherPubkey = this.getOtherPartyPubkey(event, myPubkey)
|
||||||
|
if (!otherPubkey) return null
|
||||||
|
|
||||||
|
const decryptedContent = await encryption.nip04Decrypt(otherPubkey, event.content)
|
||||||
|
|
||||||
|
// Cache in both layers
|
||||||
|
setCachedPlaintext(event.id, decryptedContent)
|
||||||
|
indexedDb.putDecryptedContent(event.id, decryptedContent).catch(() => {})
|
||||||
|
|
||||||
|
return this.buildDirectMessage(event, decryptedContent, myPubkey, 'nip04')
|
||||||
|
} else if (event.kind === KIND_GIFT_WRAP) {
|
||||||
|
// NIP-17 - check in-memory cache first
|
||||||
|
const memCached = getCachedPlaintext(event.id)
|
||||||
|
if (memCached) {
|
||||||
|
// We stored "pubkey|recipient|content" format in memory for NIP-17
|
||||||
|
const parts = memCached.split('|', 3)
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [senderPubkey, recipientPubkey, content] = parts
|
||||||
|
if (recipientPubkey === '__reaction__') return null
|
||||||
|
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
senderPubkey,
|
||||||
|
recipientPubkey,
|
||||||
|
content,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
encryptionType: 'nip17',
|
||||||
|
event,
|
||||||
|
decryptedContent: content,
|
||||||
|
seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IndexedDB cache (includes sender info)
|
||||||
|
const cachedUnwrapped = await indexedDb.getUnwrappedGiftWrap(event.id)
|
||||||
|
if (cachedUnwrapped) {
|
||||||
|
// Skip reactions in cache for now (they're stored but not returned as messages)
|
||||||
|
if (cachedUnwrapped.recipientPubkey === '__reaction__') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Populate in-memory cache
|
||||||
|
setCachedPlaintext(event.id, `${cachedUnwrapped.pubkey}|${cachedUnwrapped.recipientPubkey}|${cachedUnwrapped.content}`)
|
||||||
|
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
senderPubkey: cachedUnwrapped.pubkey,
|
||||||
|
recipientPubkey: cachedUnwrapped.recipientPubkey,
|
||||||
|
content: cachedUnwrapped.content,
|
||||||
|
createdAt: cachedUnwrapped.createdAt,
|
||||||
|
encryptionType: 'nip17',
|
||||||
|
event,
|
||||||
|
decryptedContent: cachedUnwrapped.content,
|
||||||
|
seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt (unwrap gift wrap -> unseal -> decrypt)
|
||||||
|
const unwrapped = await this.unwrapGiftWrap(event, encryption)
|
||||||
|
if (!unwrapped) return null
|
||||||
|
|
||||||
|
const innerEvent = unwrapped.innerEvent
|
||||||
|
|
||||||
|
// Handle reactions - cache them but don't return as messages
|
||||||
|
if (unwrapped.type === 'reaction') {
|
||||||
|
// Cache the reaction for later display
|
||||||
|
// TODO: Store reaction separately and associate with target message via 'e' tag
|
||||||
|
indexedDb
|
||||||
|
.putUnwrappedGiftWrap(event.id, {
|
||||||
|
pubkey: innerEvent.pubkey,
|
||||||
|
recipientPubkey: '__reaction__', // Marker for reactions
|
||||||
|
content: unwrapped.content, // The emoji
|
||||||
|
createdAt: innerEvent.created_at
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
// For now, just skip reactions (they're cached for future use)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientPubkey = this.getRecipientFromTags(innerEvent.tags) || myPubkey
|
||||||
|
|
||||||
|
// Cache in both layers
|
||||||
|
setCachedPlaintext(event.id, `${innerEvent.pubkey}|${recipientPubkey}|${unwrapped.content}`)
|
||||||
|
indexedDb
|
||||||
|
.putUnwrappedGiftWrap(event.id, {
|
||||||
|
pubkey: innerEvent.pubkey,
|
||||||
|
recipientPubkey,
|
||||||
|
content: unwrapped.content,
|
||||||
|
createdAt: innerEvent.created_at
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
senderPubkey: innerEvent.pubkey,
|
||||||
|
recipientPubkey,
|
||||||
|
content: unwrapped.content,
|
||||||
|
createdAt: innerEvent.created_at,
|
||||||
|
encryptionType: 'nip17',
|
||||||
|
event,
|
||||||
|
decryptedContent: unwrapped.content,
|
||||||
|
seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap a NIP-59 gift wrap to get the inner message or reaction
|
||||||
|
*/
|
||||||
|
private async unwrapGiftWrap(
|
||||||
|
giftWrap: Event,
|
||||||
|
encryption: IDMEncryption
|
||||||
|
): Promise<{ content: string; innerEvent: Event; type: 'dm' | 'reaction' } | null> {
|
||||||
|
try {
|
||||||
|
// Step 1: Decrypt the gift wrap content using NIP-44
|
||||||
|
if (!encryption.nip44Decrypt) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sealJson = await encryption.nip44Decrypt(giftWrap.pubkey, giftWrap.content)
|
||||||
|
const seal = JSON.parse(sealJson) as Event
|
||||||
|
|
||||||
|
if (seal.kind !== KIND_SEAL) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Decrypt the seal content using NIP-44
|
||||||
|
const innerEventJson = await encryption.nip44Decrypt(seal.pubkey, seal.content)
|
||||||
|
const innerEvent = JSON.parse(innerEventJson) as Event
|
||||||
|
|
||||||
|
if (innerEvent.kind === KIND_PRIVATE_DM) {
|
||||||
|
return {
|
||||||
|
content: innerEvent.content,
|
||||||
|
innerEvent,
|
||||||
|
type: 'dm'
|
||||||
|
}
|
||||||
|
} else if (innerEvent.kind === KIND_REACTION) {
|
||||||
|
return {
|
||||||
|
content: innerEvent.content, // The emoji
|
||||||
|
innerEvent,
|
||||||
|
type: 'reaction'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Silently ignore other event types (e.g., read receipts)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a TDirectMessage from an event
|
||||||
|
*/
|
||||||
|
private buildDirectMessage(
|
||||||
|
event: Event,
|
||||||
|
decryptedContent: string,
|
||||||
|
myPubkey: string,
|
||||||
|
encryptionType: TDMEncryptionType = 'nip04'
|
||||||
|
): TDirectMessage {
|
||||||
|
const recipient = this.getRecipientFromTags(event.tags)
|
||||||
|
const isSender = event.pubkey === myPubkey
|
||||||
|
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
senderPubkey: event.pubkey,
|
||||||
|
recipientPubkey: recipient || (isSender ? '' : myPubkey),
|
||||||
|
content: decryptedContent,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
encryptionType,
|
||||||
|
event,
|
||||||
|
decryptedContent,
|
||||||
|
seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a DM to a recipient
|
||||||
|
* When no existing conversation, sends in BOTH formats (NIP-04 and NIP-17)
|
||||||
|
*/
|
||||||
|
async sendDM(
|
||||||
|
recipientPubkey: string,
|
||||||
|
content: string,
|
||||||
|
encryption: IDMEncryption,
|
||||||
|
relayUrls: string[],
|
||||||
|
_preferNip44: boolean,
|
||||||
|
existingEncryption: TDMEncryptionType | null
|
||||||
|
): Promise<Event[]> {
|
||||||
|
const sentEvents: Event[] = []
|
||||||
|
|
||||||
|
// Get recipient's relays for better delivery
|
||||||
|
// Use inbox relays for NIP-17 (where recipient receives messages)
|
||||||
|
// Use write relays for NIP-04 (where recipient publishes from)
|
||||||
|
const [recipientInboxRelays, recipientWriteRelays] = await Promise.all([
|
||||||
|
this.fetchPartnerInboxRelays(recipientPubkey),
|
||||||
|
this.fetchPartnerRelays(recipientPubkey)
|
||||||
|
])
|
||||||
|
const allRelays = [...new Set([...relayUrls, ...recipientWriteRelays])]
|
||||||
|
const inboxRelays = [...new Set([...relayUrls, ...recipientInboxRelays])]
|
||||||
|
|
||||||
|
if (existingEncryption === null) {
|
||||||
|
// No existing conversation - send in BOTH formats
|
||||||
|
try {
|
||||||
|
const nip04Event = await this.createAndPublishNip04DM(
|
||||||
|
recipientPubkey,
|
||||||
|
content,
|
||||||
|
encryption,
|
||||||
|
allRelays
|
||||||
|
)
|
||||||
|
sentEvents.push(nip04Event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send NIP-04 DM:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (encryption.nip44Encrypt) {
|
||||||
|
// Use inbox relays for NIP-17 delivery
|
||||||
|
const nip17Event = await this.createAndPublishNip17DM(
|
||||||
|
recipientPubkey,
|
||||||
|
content,
|
||||||
|
encryption,
|
||||||
|
inboxRelays
|
||||||
|
)
|
||||||
|
sentEvents.push(nip17Event)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send NIP-17 DM:', error)
|
||||||
|
}
|
||||||
|
} else if (existingEncryption === 'nip04') {
|
||||||
|
// Match existing NIP-04 encryption
|
||||||
|
try {
|
||||||
|
const nip04Event = await this.createAndPublishNip04DM(
|
||||||
|
recipientPubkey,
|
||||||
|
content,
|
||||||
|
encryption,
|
||||||
|
allRelays
|
||||||
|
)
|
||||||
|
sentEvents.push(nip04Event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send NIP-04 DM:', error)
|
||||||
|
throw error // Re-throw so caller knows it failed
|
||||||
|
}
|
||||||
|
} else if (existingEncryption === 'nip17') {
|
||||||
|
// Match existing NIP-17 encryption - use inbox relays
|
||||||
|
if (!encryption.nip44Encrypt) {
|
||||||
|
throw new Error('Encryption does not support NIP-44')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nip17Event = await this.createAndPublishNip17DM(
|
||||||
|
recipientPubkey,
|
||||||
|
content,
|
||||||
|
encryption,
|
||||||
|
inboxRelays
|
||||||
|
)
|
||||||
|
sentEvents.push(nip17Event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send NIP-17 DM:', error)
|
||||||
|
throw error // Re-throw so caller knows it failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sentEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and publish a NIP-04 DM (kind 4)
|
||||||
|
*/
|
||||||
|
private async createAndPublishNip04DM(
|
||||||
|
recipientPubkey: string,
|
||||||
|
content: string,
|
||||||
|
encryption: IDMEncryption,
|
||||||
|
relayUrls: string[]
|
||||||
|
): Promise<VerifiedEvent> {
|
||||||
|
const encryptedContent = await encryption.nip04Encrypt(recipientPubkey, content)
|
||||||
|
|
||||||
|
const draftEvent: TDraftEvent = {
|
||||||
|
kind: KIND_ENCRYPTED_DM,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: encryptedContent,
|
||||||
|
tags: [['p', recipientPubkey]]
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedEvent = await encryption.signEvent(draftEvent)
|
||||||
|
await client.publishEvent(relayUrls, signedEvent)
|
||||||
|
await indexedDb.putDMEvent(signedEvent)
|
||||||
|
await indexedDb.putDecryptedContent(signedEvent.id, content)
|
||||||
|
|
||||||
|
return signedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and publish a NIP-17 DM with gift wrapping (kind 14 -> 13 -> 1059)
|
||||||
|
*/
|
||||||
|
private async createAndPublishNip17DM(
|
||||||
|
recipientPubkey: string,
|
||||||
|
content: string,
|
||||||
|
encryption: IDMEncryption,
|
||||||
|
relayUrls: string[]
|
||||||
|
): Promise<VerifiedEvent> {
|
||||||
|
if (!encryption.nip44Encrypt) {
|
||||||
|
throw new Error('Encryption does not support NIP-44')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: senderPubkey is determined by the signer when signing the event
|
||||||
|
|
||||||
|
// Step 1: Create the inner chat message (kind 14)
|
||||||
|
const chatMessage: TDraftEvent = {
|
||||||
|
kind: KIND_PRIVATE_DM,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content,
|
||||||
|
tags: [['p', recipientPubkey]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Sign the chat message
|
||||||
|
const signedChat = await encryption.signEvent(chatMessage)
|
||||||
|
|
||||||
|
// Step 3: Create a seal (kind 13) containing the encrypted chat message
|
||||||
|
const sealContent = await encryption.nip44Encrypt(recipientPubkey, JSON.stringify(signedChat))
|
||||||
|
const seal: TDraftEvent = {
|
||||||
|
kind: KIND_SEAL,
|
||||||
|
created_at: this.randomizeTimestamp(signedChat.created_at),
|
||||||
|
content: sealContent,
|
||||||
|
tags: []
|
||||||
|
}
|
||||||
|
const signedSeal = await encryption.signEvent(seal)
|
||||||
|
|
||||||
|
// Step 4: Create a gift wrap (kind 1059) with random sender key
|
||||||
|
// For simplicity, we'll use the same encryption but in production you'd use a random key
|
||||||
|
const giftWrapContent = await encryption.nip44Encrypt(recipientPubkey, JSON.stringify(signedSeal))
|
||||||
|
const giftWrap: TDraftEvent = {
|
||||||
|
kind: KIND_GIFT_WRAP,
|
||||||
|
created_at: this.randomizeTimestamp(signedSeal.created_at),
|
||||||
|
content: giftWrapContent,
|
||||||
|
tags: [['p', recipientPubkey]]
|
||||||
|
}
|
||||||
|
const signedGiftWrap = await encryption.signEvent(giftWrap)
|
||||||
|
|
||||||
|
// Publish the gift wrap
|
||||||
|
await client.publishEvent(relayUrls, signedGiftWrap)
|
||||||
|
await indexedDb.putDMEvent(signedGiftWrap)
|
||||||
|
await indexedDb.putDecryptedContent(signedGiftWrap.id, content)
|
||||||
|
|
||||||
|
return signedGiftWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Randomize timestamp for privacy (NIP-59)
|
||||||
|
*/
|
||||||
|
private randomizeTimestamp(baseTime: number): number {
|
||||||
|
// Add random offset between -2 days and +2 days
|
||||||
|
const offset = Math.floor(Math.random() * 4 * 24 * 60 * 60) - 2 * 24 * 60 * 60
|
||||||
|
return baseTime + offset
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch partner's write relays for better DM delivery
|
||||||
|
*/
|
||||||
|
async fetchPartnerRelays(pubkey: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// Try to get relay list from IndexedDB first
|
||||||
|
const cachedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
|
||||||
|
if (cachedEvent) {
|
||||||
|
return this.parseWriteRelays(cachedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from relays
|
||||||
|
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
|
||||||
|
kinds: [kinds.RelayList],
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
if (relayListEvents.length > 0) {
|
||||||
|
const event = relayListEvents[0]
|
||||||
|
await indexedDb.putReplaceableEvent(event)
|
||||||
|
return this.parseWriteRelays(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to archive relay
|
||||||
|
return [ARCHIVE_RELAY_URL]
|
||||||
|
} catch {
|
||||||
|
return [ARCHIVE_RELAY_URL]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch partner's inbox (read) relays for NIP-17 DM delivery
|
||||||
|
* NIP-65: Inbox relays are where a user receives messages
|
||||||
|
*/
|
||||||
|
async fetchPartnerInboxRelays(pubkey: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// Try to get relay list from IndexedDB first
|
||||||
|
const cachedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
|
||||||
|
if (cachedEvent) {
|
||||||
|
return this.parseInboxRelays(cachedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from relays
|
||||||
|
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
|
||||||
|
kinds: [kinds.RelayList],
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
if (relayListEvents.length > 0) {
|
||||||
|
const event = relayListEvents[0]
|
||||||
|
await indexedDb.putReplaceableEvent(event)
|
||||||
|
return this.parseInboxRelays(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to big relays
|
||||||
|
return BIG_RELAY_URLS
|
||||||
|
} catch {
|
||||||
|
return BIG_RELAY_URLS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse write (outbox) relays from kind 10002 event
|
||||||
|
*/
|
||||||
|
private parseWriteRelays(event: Event): string[] {
|
||||||
|
const writeRelays: string[] = []
|
||||||
|
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'r') {
|
||||||
|
const url = tag[1]
|
||||||
|
const scope = tag[2]
|
||||||
|
// Include if it's a write relay or has no scope (both)
|
||||||
|
if (!scope || scope === 'write') {
|
||||||
|
writeRelays.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeRelays.length > 0 ? writeRelays : [ARCHIVE_RELAY_URL]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse inbox (read) relays from kind 10002 event
|
||||||
|
* These are where the user receives DMs
|
||||||
|
*/
|
||||||
|
private parseInboxRelays(event: Event): string[] {
|
||||||
|
const inboxRelays: string[] = []
|
||||||
|
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'r') {
|
||||||
|
const url = tag[1]
|
||||||
|
const scope = tag[2]
|
||||||
|
// Include if it's a read relay or has no scope (both)
|
||||||
|
if (!scope || scope === 'read') {
|
||||||
|
inboxRelays.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inboxRelays.length > 0 ? inboxRelays : BIG_RELAY_URLS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check other relays for an event and return which ones have it
|
||||||
|
*/
|
||||||
|
async checkOtherRelaysForEvent(
|
||||||
|
eventId: string,
|
||||||
|
knownRelays: string[]
|
||||||
|
): Promise<string[]> {
|
||||||
|
const knownSet = new Set(knownRelays.map((r) => r.replace(/\/$/, '')))
|
||||||
|
const relaysToCheck = BIG_RELAY_URLS.filter(
|
||||||
|
(url) => !knownSet.has(url.replace(/\/$/, ''))
|
||||||
|
)
|
||||||
|
|
||||||
|
const foundOnRelays: string[] = []
|
||||||
|
|
||||||
|
// Check each relay individually
|
||||||
|
await Promise.all(
|
||||||
|
relaysToCheck.map(async (relayUrl) => {
|
||||||
|
try {
|
||||||
|
const events = await client.fetchEvents([relayUrl], {
|
||||||
|
ids: [eventId],
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
if (events.length > 0) {
|
||||||
|
foundOnRelays.push(relayUrl)
|
||||||
|
// Track the event as seen on this relay
|
||||||
|
client.trackEventSeenOn(eventId, { url: relayUrl } as any)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Relay unreachable, ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return foundOnRelays
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group messages into conversations
|
||||||
|
*/
|
||||||
|
groupMessagesIntoConversations(
|
||||||
|
messages: TDirectMessage[],
|
||||||
|
myPubkey: string
|
||||||
|
): Map<string, TConversation> {
|
||||||
|
const conversations = new Map<string, TConversation>()
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const partnerPubkey =
|
||||||
|
message.senderPubkey === myPubkey ? message.recipientPubkey : message.senderPubkey
|
||||||
|
|
||||||
|
if (!partnerPubkey) continue
|
||||||
|
|
||||||
|
const existing = conversations.get(partnerPubkey)
|
||||||
|
if (!existing || message.createdAt > existing.lastMessageAt) {
|
||||||
|
conversations.set(partnerPubkey, {
|
||||||
|
partnerPubkey,
|
||||||
|
lastMessageAt: message.createdAt,
|
||||||
|
lastMessagePreview: message.content.substring(0, 100),
|
||||||
|
unreadCount: 0,
|
||||||
|
preferredEncryption: message.encryptionType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build conversation list from raw events WITHOUT decryption (fast)
|
||||||
|
* Only works for NIP-04 events - NIP-17 gift wraps need decryption
|
||||||
|
*/
|
||||||
|
groupEventsIntoConversations(events: Event[], myPubkey: string): Map<string, TConversation> {
|
||||||
|
const conversations = new Map<string, TConversation>()
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
// Only process NIP-04 events (kind 4) - we can get metadata without decryption
|
||||||
|
if (event.kind !== KIND_ENCRYPTED_DM) continue
|
||||||
|
|
||||||
|
const recipient = this.getRecipientFromTags(event.tags)
|
||||||
|
const partnerPubkey = event.pubkey === myPubkey ? recipient : event.pubkey
|
||||||
|
|
||||||
|
if (!partnerPubkey) continue
|
||||||
|
|
||||||
|
const existing = conversations.get(partnerPubkey)
|
||||||
|
if (!existing || event.created_at > existing.lastMessageAt) {
|
||||||
|
conversations.set(partnerPubkey, {
|
||||||
|
partnerPubkey,
|
||||||
|
lastMessageAt: event.created_at,
|
||||||
|
lastMessagePreview: '', // Skip preview for speed - will be filled on conversation open
|
||||||
|
unreadCount: 0,
|
||||||
|
preferredEncryption: 'nip04'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get messages for a specific conversation
|
||||||
|
*/
|
||||||
|
getMessagesForConversation(
|
||||||
|
messages: TDirectMessage[],
|
||||||
|
partnerPubkey: string,
|
||||||
|
myPubkey: string
|
||||||
|
): TDirectMessage[] {
|
||||||
|
return messages
|
||||||
|
.filter(
|
||||||
|
(m) =>
|
||||||
|
(m.senderPubkey === partnerPubkey && m.recipientPubkey === myPubkey) ||
|
||||||
|
(m.senderPubkey === myPubkey && m.recipientPubkey === partnerPubkey)
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the other party's pubkey from a DM event
|
||||||
|
*/
|
||||||
|
private getOtherPartyPubkey(event: Event, myPubkey: string): string | null {
|
||||||
|
if (event.pubkey === myPubkey) {
|
||||||
|
// I'm the sender, get recipient from tags
|
||||||
|
return this.getRecipientFromTags(event.tags)
|
||||||
|
} else {
|
||||||
|
// I'm the recipient, sender is the pubkey
|
||||||
|
return event.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recipient pubkey from event tags
|
||||||
|
*/
|
||||||
|
private getRecipientFromTags(tags: string[][]): string | null {
|
||||||
|
const pTag = tags.find((t) => t[0] === 'p')
|
||||||
|
return pTag ? pTag[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to incoming DMs in real-time
|
||||||
|
* Returns a close function to stop the subscription
|
||||||
|
*/
|
||||||
|
subscribeToDMs(
|
||||||
|
pubkey: string,
|
||||||
|
relayUrls: string[],
|
||||||
|
onEvent: (event: Event) => void
|
||||||
|
): { close: () => void } {
|
||||||
|
const allRelays = [...new Set([...relayUrls, ...BIG_RELAY_URLS])]
|
||||||
|
const since = Math.floor(Date.now() / 1000) - 60 // Start from 1 minute ago to catch recent
|
||||||
|
|
||||||
|
// Subscribe to NIP-04 DMs (kind 4) addressed to user
|
||||||
|
const nip04Sub = client.subscribe(
|
||||||
|
allRelays,
|
||||||
|
[
|
||||||
|
{ kinds: [KIND_ENCRYPTED_DM], '#p': [pubkey], since },
|
||||||
|
{ kinds: [KIND_ENCRYPTED_DM], authors: [pubkey], since }
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent: (event) => {
|
||||||
|
indexedDb.putDMEvent(event).catch(() => {})
|
||||||
|
onEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe to NIP-17 gift wraps (kind 1059) addressed to user
|
||||||
|
const giftWrapSub = client.subscribe(
|
||||||
|
allRelays,
|
||||||
|
{ kinds: [KIND_GIFT_WRAP], '#p': [pubkey], since },
|
||||||
|
{
|
||||||
|
onevent: (event) => {
|
||||||
|
indexedDb.putDMEvent(event).catch(() => {})
|
||||||
|
onEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
const [nip04, giftWrap] = await Promise.all([nip04Sub, giftWrapSub])
|
||||||
|
nip04.close()
|
||||||
|
giftWrap.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmService = new DMService()
|
||||||
|
export default dmService
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a message should be treated as deleted based on the deleted state
|
||||||
|
* @param messageId - The event ID of the message
|
||||||
|
* @param partnerPubkey - The conversation partner's pubkey
|
||||||
|
* @param timestamp - The message timestamp (created_at)
|
||||||
|
* @param deletedState - The user's deleted messages state
|
||||||
|
* @returns true if the message should be hidden
|
||||||
|
*/
|
||||||
|
export function isMessageDeleted(
|
||||||
|
messageId: string,
|
||||||
|
partnerPubkey: string,
|
||||||
|
timestamp: number,
|
||||||
|
deletedState: TDMDeletedState | null
|
||||||
|
): boolean {
|
||||||
|
if (!deletedState) return false
|
||||||
|
|
||||||
|
// Check if message ID is explicitly deleted
|
||||||
|
if (deletedState.deletedIds.includes(messageId)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if timestamp falls within any deleted range for this conversation
|
||||||
|
const ranges = deletedState.deletedRanges[partnerPubkey]
|
||||||
|
if (ranges) {
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (timestamp >= range.start && timestamp <= range.end) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a conversation should be hidden based on its last message timestamp
|
||||||
|
* A conversation is deleted if its lastMessageAt falls within any deleted range
|
||||||
|
* @param partnerPubkey - The conversation partner's pubkey
|
||||||
|
* @param lastMessageAt - The timestamp of the last message in the conversation
|
||||||
|
* @param deletedState - The user's deleted messages state
|
||||||
|
* @returns true if the conversation should be hidden
|
||||||
|
*/
|
||||||
|
export function isConversationDeleted(
|
||||||
|
partnerPubkey: string,
|
||||||
|
lastMessageAt: number,
|
||||||
|
deletedState: TDMDeletedState | null
|
||||||
|
): boolean {
|
||||||
|
if (!deletedState) return false
|
||||||
|
|
||||||
|
const ranges = deletedState.deletedRanges[partnerPubkey]
|
||||||
|
if (!ranges || ranges.length === 0) return false
|
||||||
|
|
||||||
|
// Check if lastMessageAt falls within any deleted range
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (lastMessageAt >= range.start && lastMessageAt <= range.end) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { TRelayInfo } from '@/types'
|
import { TDMDeletedState, TRelayInfo } from '@/types'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
|
|
||||||
type TValue<T = any> = {
|
type TValue<T = any> = {
|
||||||
@@ -25,6 +25,11 @@ const StoreNames = {
|
|||||||
RELAY_INFOS: 'relayInfos',
|
RELAY_INFOS: 'relayInfos',
|
||||||
DECRYPTED_CONTENTS: 'decryptedContents',
|
DECRYPTED_CONTENTS: 'decryptedContents',
|
||||||
PINNED_USERS_EVENTS: 'pinnedUsersEvents',
|
PINNED_USERS_EVENTS: 'pinnedUsersEvents',
|
||||||
|
DM_EVENTS: 'dmEvents',
|
||||||
|
DM_CONVERSATIONS: 'dmConversations',
|
||||||
|
DM_MESSAGES: 'dmMessages',
|
||||||
|
UNWRAPPED_GIFT_WRAPS: 'unwrappedGiftWraps',
|
||||||
|
DM_DELETED_STATE: 'dmDeletedState',
|
||||||
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated
|
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated
|
||||||
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
|
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
|
||||||
}
|
}
|
||||||
@@ -45,7 +50,7 @@ class IndexedDbService {
|
|||||||
init(): Promise<void> {
|
init(): Promise<void> {
|
||||||
if (!this.initPromise) {
|
if (!this.initPromise) {
|
||||||
this.initPromise = new Promise((resolve, reject) => {
|
this.initPromise = new Promise((resolve, reject) => {
|
||||||
const request = window.indexedDB.open('smesh', 10)
|
const request = window.indexedDB.open('smesh', 14)
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
reject(event)
|
reject(event)
|
||||||
@@ -103,6 +108,21 @@ class IndexedDbService {
|
|||||||
if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) {
|
if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) {
|
||||||
db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' })
|
db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' })
|
||||||
}
|
}
|
||||||
|
if (!db.objectStoreNames.contains(StoreNames.DM_EVENTS)) {
|
||||||
|
db.createObjectStore(StoreNames.DM_EVENTS, { keyPath: 'key' })
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(StoreNames.DM_CONVERSATIONS)) {
|
||||||
|
db.createObjectStore(StoreNames.DM_CONVERSATIONS, { keyPath: 'key' })
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(StoreNames.DM_MESSAGES)) {
|
||||||
|
db.createObjectStore(StoreNames.DM_MESSAGES, { keyPath: 'key' })
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(StoreNames.UNWRAPPED_GIFT_WRAPS)) {
|
||||||
|
db.createObjectStore(StoreNames.UNWRAPPED_GIFT_WRAPS, { keyPath: 'key' })
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(StoreNames.DM_DELETED_STATE)) {
|
||||||
|
db.createObjectStore(StoreNames.DM_DELETED_STATE, { keyPath: 'key' })
|
||||||
|
}
|
||||||
|
|
||||||
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
|
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
|
||||||
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
|
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
|
||||||
@@ -440,6 +460,505 @@ class IndexedDbService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DM-related methods
|
||||||
|
async putDMEvent(event: Event): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_EVENTS)
|
||||||
|
|
||||||
|
const putRequest = store.put(this.formatValue(event.id, event))
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDMEvent(eventId: string): Promise<Event | null> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readonly')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_EVENTS)
|
||||||
|
const request = store.get(eventId)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve((request.result as TValue<Event>)?.value ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllDMEvents(userPubkey: string): Promise<Event[]> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readonly')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_EVENTS)
|
||||||
|
const request = store.openCursor()
|
||||||
|
const events: Event[] = []
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest).result
|
||||||
|
if (cursor) {
|
||||||
|
const dmEvent = (cursor.value as TValue<Event>).value
|
||||||
|
if (dmEvent) {
|
||||||
|
// Include events where user is sender or recipient
|
||||||
|
const isUserEvent =
|
||||||
|
dmEvent.pubkey === userPubkey ||
|
||||||
|
dmEvent.tags.some((tag) => tag[0] === 'p' && tag[1] === userPubkey)
|
||||||
|
if (isUserEvent) {
|
||||||
|
events.push(dmEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
transaction.commit()
|
||||||
|
resolve(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async putDMConversation(
|
||||||
|
userPubkey: string,
|
||||||
|
partnerPubkey: string,
|
||||||
|
lastMessageAt: number,
|
||||||
|
lastMessagePreview: string,
|
||||||
|
encryptionType: 'nip04' | 'nip17' | null
|
||||||
|
): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
|
||||||
|
const key = `${userPubkey}:${partnerPubkey}`
|
||||||
|
|
||||||
|
const putRequest = store.put(
|
||||||
|
this.formatValue(key, {
|
||||||
|
partnerPubkey,
|
||||||
|
lastMessageAt,
|
||||||
|
lastMessagePreview,
|
||||||
|
encryptionType
|
||||||
|
})
|
||||||
|
)
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDMConversations(
|
||||||
|
userPubkey: string
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
partnerPubkey: string
|
||||||
|
lastMessageAt: number
|
||||||
|
lastMessagePreview: string
|
||||||
|
encryptionType: 'nip04' | 'nip17' | null
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
|
||||||
|
const request = store.openCursor()
|
||||||
|
const conversations: Array<{
|
||||||
|
partnerPubkey: string
|
||||||
|
lastMessageAt: number
|
||||||
|
lastMessagePreview: string
|
||||||
|
encryptionType: 'nip04' | 'nip17' | null
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest).result
|
||||||
|
if (cursor) {
|
||||||
|
const key = cursor.key as string
|
||||||
|
if (key.startsWith(`${userPubkey}:`)) {
|
||||||
|
const value = (cursor.value as TValue).value
|
||||||
|
if (value) {
|
||||||
|
conversations.push(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
transaction.commit()
|
||||||
|
// Sort by lastMessageAt descending
|
||||||
|
conversations.sort((a, b) => b.lastMessageAt - a.lastMessageAt)
|
||||||
|
resolve(conversations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async putConversationRelaySettings(
|
||||||
|
userPubkey: string,
|
||||||
|
partnerPubkey: string,
|
||||||
|
selectedRelays: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
|
||||||
|
const key = `${userPubkey}:${partnerPubkey}:relays`
|
||||||
|
|
||||||
|
const putRequest = store.put(this.formatValue(key, { selectedRelays }))
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversationRelaySettings(
|
||||||
|
userPubkey: string,
|
||||||
|
partnerPubkey: string
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
|
||||||
|
const key = `${userPubkey}:${partnerPubkey}:relays`
|
||||||
|
const request = store.get(key)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
const result = (request.result as TValue)?.value
|
||||||
|
resolve(result?.selectedRelays ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async putConversationEncryptionPreference(
|
||||||
|
userPubkey: string,
|
||||||
|
partnerPubkey: string,
|
||||||
|
preference: 'nip04' | 'nip17' | 'auto'
|
||||||
|
): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
|
||||||
|
const key = `${userPubkey}:${partnerPubkey}:encryption`
|
||||||
|
|
||||||
|
const putRequest = store.put(this.formatValue(key, { preference }))
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversationEncryptionPreference(
|
||||||
|
userPubkey: string,
|
||||||
|
partnerPubkey: string
|
||||||
|
): Promise<'nip04' | 'nip17' | 'auto' | null> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
|
||||||
|
const key = `${userPubkey}:${partnerPubkey}:encryption`
|
||||||
|
const request = store.get(key)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
const result = (request.result as TValue)?.value
|
||||||
|
resolve(result?.preference ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async putConversationMessages(
|
||||||
|
userPubkey: string,
|
||||||
|
partnerPubkey: string,
|
||||||
|
messages: Array<{
|
||||||
|
id: string
|
||||||
|
senderPubkey: string
|
||||||
|
recipientPubkey: string
|
||||||
|
content: string
|
||||||
|
createdAt: number
|
||||||
|
encryptionType: 'nip04' | 'nip17'
|
||||||
|
seenOnRelays?: string[]
|
||||||
|
}>
|
||||||
|
): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_MESSAGES)
|
||||||
|
const key = `${userPubkey}:${partnerPubkey}`
|
||||||
|
|
||||||
|
const putRequest = store.put(this.formatValue(key, messages))
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversationMessages(
|
||||||
|
userPubkey: string,
|
||||||
|
partnerPubkey: string
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string
|
||||||
|
senderPubkey: string
|
||||||
|
recipientPubkey: string
|
||||||
|
content: string
|
||||||
|
createdAt: number
|
||||||
|
encryptionType: 'nip04' | 'nip17'
|
||||||
|
seenOnRelays?: string[]
|
||||||
|
}> | null
|
||||||
|
> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readonly')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_MESSAGES)
|
||||||
|
const key = `${userPubkey}:${partnerPubkey}`
|
||||||
|
const request = store.get(key)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve((request.result as TValue)?.value ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache an unwrapped NIP-17 gift wrap inner event
|
||||||
|
* This avoids repeated decryption just to identify the sender
|
||||||
|
*/
|
||||||
|
async putUnwrappedGiftWrap(
|
||||||
|
giftWrapId: string,
|
||||||
|
innerEvent: {
|
||||||
|
pubkey: string // actual sender
|
||||||
|
recipientPubkey: string
|
||||||
|
content: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
|
||||||
|
|
||||||
|
const putRequest = store.put(this.formatValue(giftWrapId, innerEvent))
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cached unwrapped NIP-17 gift wrap inner event
|
||||||
|
*/
|
||||||
|
async getUnwrappedGiftWrap(
|
||||||
|
giftWrapId: string
|
||||||
|
): Promise<{
|
||||||
|
pubkey: string
|
||||||
|
recipientPubkey: string
|
||||||
|
content: string
|
||||||
|
createdAt: number
|
||||||
|
} | null> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readonly')
|
||||||
|
const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
|
||||||
|
const request = store.get(giftWrapId)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve((request.result as TValue)?.value ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all DM-related caches (for full refresh)
|
||||||
|
*/
|
||||||
|
async clearAllDMCaches(): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
if (!this.db) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeNames = [
|
||||||
|
StoreNames.DM_EVENTS,
|
||||||
|
StoreNames.DM_CONVERSATIONS,
|
||||||
|
StoreNames.DM_MESSAGES,
|
||||||
|
StoreNames.UNWRAPPED_GIFT_WRAPS,
|
||||||
|
StoreNames.DECRYPTED_CONTENTS
|
||||||
|
]
|
||||||
|
|
||||||
|
const transaction = this.db.transaction(storeNames, 'readwrite')
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
storeNames.map(
|
||||||
|
(storeName) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const request = store.clear()
|
||||||
|
request.onsuccess = () => resolve()
|
||||||
|
request.onerror = (event) => reject(event)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the deleted messages state for a user (local cache only)
|
||||||
|
*/
|
||||||
|
async getDeletedMessagesState(pubkey: string): Promise<TDMDeletedState | null> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readonly')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
|
||||||
|
const request = store.get(pubkey)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve((request.result as TValue<TDMDeletedState>)?.value ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the deleted messages state for a user (local cache)
|
||||||
|
*/
|
||||||
|
async putDeletedMessagesState(pubkey: string, state: TDMDeletedState): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
|
||||||
|
|
||||||
|
const putRequest = store.put(this.formatValue(pubkey, state))
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private getReplaceableEventKeyFromEvent(event: Event): string {
|
private getReplaceableEventKeyFromEvent(event: Event): string {
|
||||||
if (
|
if (
|
||||||
[kinds.Metadata, kinds.Contacts].includes(event.kind) ||
|
[kinds.Metadata, kinds.Contacts].includes(event.kind) ||
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ class LocalStorageService {
|
|||||||
private quickReaction: boolean = false
|
private quickReaction: boolean = false
|
||||||
private quickReactionEmoji: string | TEmoji = '+'
|
private quickReactionEmoji: string | TEmoji = '+'
|
||||||
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
|
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
|
||||||
|
private preferNip44: boolean = false
|
||||||
|
private dmConversationFilter: 'all' | 'follows' = 'all'
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!LocalStorageService.instance) {
|
if (!LocalStorageService.instance) {
|
||||||
@@ -248,6 +250,10 @@ class LocalStorageService {
|
|||||||
this.quickReactionEmoji = quickReactionEmojiStr
|
this.quickReactionEmoji = quickReactionEmojiStr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.preferNip44 = window.localStorage.getItem(StorageKey.PREFER_NIP44) === 'true'
|
||||||
|
this.dmConversationFilter =
|
||||||
|
(window.localStorage.getItem(StorageKey.DM_CONVERSATION_FILTER) as 'all' | 'follows') || 'all'
|
||||||
|
|
||||||
// Clean up deprecated data
|
// Clean up deprecated data
|
||||||
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
|
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||||
@@ -586,6 +592,49 @@ class LocalStorageService {
|
|||||||
this.nsfwDisplayPolicy = policy
|
this.nsfwDisplayPolicy = policy
|
||||||
window.localStorage.setItem(StorageKey.NSFW_DISPLAY_POLICY, policy)
|
window.localStorage.setItem(StorageKey.NSFW_DISPLAY_POLICY, policy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPreferNip44() {
|
||||||
|
return this.preferNip44
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreferNip44(prefer: boolean) {
|
||||||
|
this.preferNip44 = prefer
|
||||||
|
window.localStorage.setItem(StorageKey.PREFER_NIP44, prefer.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
getDMConversationFilter() {
|
||||||
|
return this.dmConversationFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
setDMConversationFilter(filter: 'all' | 'follows') {
|
||||||
|
this.dmConversationFilter = filter
|
||||||
|
window.localStorage.setItem(StorageKey.DM_CONVERSATION_FILTER, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDMLastSeenTimestamp(pubkey: string): number {
|
||||||
|
const mapStr = window.localStorage.getItem(StorageKey.DM_LAST_SEEN_TIMESTAMP)
|
||||||
|
if (!mapStr) return 0
|
||||||
|
try {
|
||||||
|
const map = JSON.parse(mapStr) as Record<string, number>
|
||||||
|
return map[pubkey] ?? 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDMLastSeenTimestamp(pubkey: string, timestamp: number) {
|
||||||
|
const mapStr = window.localStorage.getItem(StorageKey.DM_LAST_SEEN_TIMESTAMP)
|
||||||
|
let map: Record<string, number> = {}
|
||||||
|
if (mapStr) {
|
||||||
|
try {
|
||||||
|
map = JSON.parse(mapStr)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map[pubkey] = timestamp
|
||||||
|
window.localStorage.setItem(StorageKey.DM_LAST_SEEN_TIMESTAMP, JSON.stringify(map))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = new LocalStorageService()
|
const instance = new LocalStorageService()
|
||||||
|
|||||||
44
src/types/index.d.ts
vendored
44
src/types/index.d.ts
vendored
@@ -89,6 +89,10 @@ export type TNip07 = {
|
|||||||
encrypt?: (pubkey: string, plainText: string) => Promise<string>
|
encrypt?: (pubkey: string, plainText: string) => Promise<string>
|
||||||
decrypt?: (pubkey: string, cipherText: string) => Promise<string>
|
decrypt?: (pubkey: string, cipherText: string) => Promise<string>
|
||||||
}
|
}
|
||||||
|
nip44?: {
|
||||||
|
encrypt?: (pubkey: string, plainText: string) => Promise<string>
|
||||||
|
decrypt?: (pubkey: string, cipherText: string) => Promise<string>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISigner {
|
export interface ISigner {
|
||||||
@@ -96,6 +100,8 @@ export interface ISigner {
|
|||||||
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
|
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
|
||||||
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
|
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
|
||||||
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
||||||
|
nip44Encrypt?: (pubkey: string, plainText: string) => Promise<string>
|
||||||
|
nip44Decrypt?: (pubkey: string, cipherText: string) => Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TSignerType = 'nsec' | 'nip-07' | 'browser-nsec' | 'ncryptsec' | 'npub' | 'bunker'
|
export type TSignerType = 'nsec' | 'nip-07' | 'browser-nsec' | 'ncryptsec' | 'npub' | 'bunker'
|
||||||
@@ -109,6 +115,7 @@ export type TAccount = {
|
|||||||
bunkerPubkey?: string
|
bunkerPubkey?: string
|
||||||
bunkerRelays?: string[]
|
bunkerRelays?: string[]
|
||||||
bunkerSecret?: string
|
bunkerSecret?: string
|
||||||
|
bunkerCatToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
||||||
@@ -220,4 +227,41 @@ export type TSyncSettings = {
|
|||||||
quickReaction?: boolean
|
quickReaction?: boolean
|
||||||
quickReactionEmoji?: string | TEmoji
|
quickReactionEmoji?: string | TEmoji
|
||||||
noteListMode?: TNoteListMode
|
noteListMode?: TNoteListMode
|
||||||
|
preferNip44?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// DM types
|
||||||
|
export type TDMEncryptionType = 'nip04' | 'nip17'
|
||||||
|
|
||||||
|
export interface TConversation {
|
||||||
|
partnerPubkey: string
|
||||||
|
lastMessageAt: number
|
||||||
|
lastMessagePreview: string
|
||||||
|
unreadCount: number
|
||||||
|
preferredEncryption: TDMEncryptionType | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TDirectMessage {
|
||||||
|
id: string
|
||||||
|
senderPubkey: string
|
||||||
|
recipientPubkey: string
|
||||||
|
content: string
|
||||||
|
createdAt: number
|
||||||
|
encryptionType: TDMEncryptionType
|
||||||
|
event: Event
|
||||||
|
decryptedContent?: string
|
||||||
|
seenOnRelays?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleted messages state (stored in kind 30078 Application Specific Data)
|
||||||
|
export interface TDMDeletedState {
|
||||||
|
// Specific message IDs to ignore
|
||||||
|
deletedIds: string[]
|
||||||
|
// Timestamp ranges to ignore per conversation
|
||||||
|
deletedRanges: {
|
||||||
|
[partnerPubkey: string]: Array<{
|
||||||
|
start: number // timestamp
|
||||||
|
end: number // timestamp
|
||||||
|
}>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user